import React from 'react'; import { DndContext, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { getVisibleContextRailSurfaces, sortContextSurfaces, type ContextSurfaceDescriptor, } from '@/lib/surfaces/registry'; import { getEffectiveShortcutPrefix, isShortcutPrefixHeld, } from '@/lib/shortcuts'; import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; const RAIL_TOOLTIP_DELAY_MS = 150; // Hold the surface-switch modifier for this long before revealing the order // number badges on the rail icons. const RAIL_NUMBER_HOLD_DELAY_MS = 500; const EMPTY_TABS: never[] = []; type RailItemProps = { surface: ContextSurfaceDescriptor; isActive: boolean; showActivityDot: boolean; label: string; description: string; /** Numeric badge (e.g. the Git changed-files count); takes precedence over the activity dot. */ badgeCount?: number | null; /** Accessible label that includes the badge count; falls back to `label`. */ badgeAriaLabel?: string | null; /** Extra tooltip line describing the badge; rendered under the description. */ badgeDescription?: string | null; orderNumber?: number | null; showOrderNumber?: boolean; onSelect: (surface: ContextSurfaceDescriptor) => void; }; // The badge corner is 16px tall; cap large counts so the pill stays compact // on the 36px rail button (matching the order-number badge's footprint). const formatRailBadgeCount = (count: number): string => (count > 99 ? '99+' : String(count)); const ContextPanelRailItem: React.FC = ({ surface, isActive, showActivityDot, label, description, badgeCount, badgeAriaLabel, badgeDescription, orderNumber, showOrderNumber, onSelect, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, }); const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null; return (
{label} {description} {badgeDescription ? ( {badgeDescription} ) : null}
); }; export const ContextPanelRail: React.FC = () => { const { t } = useI18n(); const effectiveDirectory = useEffectiveDirectory(); const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''; const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const { screenWidth } = useDeviceInfo(); const gitStatus = useGitStatus(directoryKey || null); const surfaceSwitchPrefix = React.useMemo( () => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides), [shortcutOverrides], ); const [revealNumbers, setRevealNumbers] = React.useState(false); // While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS, // reveal the order number badges so users can see which digit maps to which // rail icon. Releasing (or losing focus) dismisses them, and pressing a // number key while the chord is armed consumes them for this hold — they // only come back on the next press-and-hold. React.useEffect(() => { const held = new Set(); let timer: ReturnType | null = null; let consumedWhileHeld = false; const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9'; const update = () => { const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held); if (armed) { if (!consumedWhileHeld && timer === null) { timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS); } } else { consumedWhileHeld = false; if (timer !== null) { clearTimeout(timer); timer = null; } setRevealNumbers(false); } }; const onKeyDown = (e: KeyboardEvent) => { held.add(e.key.toLowerCase()); if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) { consumedWhileHeld = true; if (timer !== null) { clearTimeout(timer); timer = null; } setRevealNumbers(false); return; } update(); }; const onKeyUp = (e: KeyboardEvent) => { held.delete(e.key.toLowerCase()); update(); }; const onWindowBlur = () => { held.clear(); consumedWhileHeld = false; if (timer !== null) { clearTimeout(timer); timer = null; } setRevealNumbers(false); }; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('keyup', onKeyUp, true); window.addEventListener('blur', onWindowBlur); return () => { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('keyup', onKeyUp, true); window.removeEventListener('blur', onWindowBlur); if (timer !== null) { clearTimeout(timer); } }; }, [surfaceSwitchPrefix]); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }), ); const tabs = panelState?.tabs ?? EMPTY_TABS; const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null; const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null; const changedFilesCount = gitStatus?.files.length ?? 0; const surfaces = React.useMemo(() => { return getVisibleContextRailSurfaces({ railOrder: contextRailOrder, planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth, tabs, }); }, [contextRailOrder, planModeEnabled, screenWidth, tabs]); const handleDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) { return; } const orderedIds = sortContextSurfaces(useUIStore.getState().contextRailOrder).map((surface) => surface.id); const fromIndex = orderedIds.indexOf(active.id as (typeof orderedIds)[number]); const toIndex = orderedIds.indexOf(over.id as (typeof orderedIds)[number]); if (fromIndex === -1 || toIndex === -1) { return; } setContextRailOrder(arrayMove(orderedIds, fromIndex, toIndex)); }, [setContextRailOrder]); if (!directoryKey) { return null; } return ( ); };