import { useRef, useEffect, useLayoutEffect, useState, useCallback, } from "react"; import { useTranslation } from "react-i18next"; import { useReducedMotion } from "motion/react"; import { Button } from "@/components/button"; import { Separator } from "@/components/separator"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { ChevronDown, ChevronUp, RefreshCw, X, LayoutPanelLeft, Plus, Minus, Pencil, Maximize2, Minimize2, FolderOpen, PanelRight, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; import { isSessionTabType } from "@/shell/tab-registry"; import { isElectron } from "@/lib/electron"; import type { Tab, TabType, SplitMode } from "@/types/ui-types"; import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme"; /** * Tabs holding a live connection that can be refreshed: the registered * session tabs. */ function isConnectionTab(type: TabType): boolean { return isSessionTabType(type); } export function TabBar({ tabs, activeTabId, splitMode, paneTabIds, focusedPaneIndex, onSetActiveTab, onCloseTab, onRefreshTab, onReorderTabs, onSplitTab, onAddToSplit, onRemoveFromSplit, onRenameTab, onOpenFileManager, isAppFullscreen, onToggleAppFullscreen, rightDockOpen, onToggleRightDock, showTabNumbers, }: { tabs: Tab[]; activeTabId: string; splitMode: SplitMode; paneTabIds: (string | null)[]; focusedPaneIndex: number | null; onSetActiveTab: (id: string) => void; onCloseTab: (id: string) => void; onRefreshTab: (id: string) => void; onReorderTabs: (tabs: Tab[]) => void; onSplitTab: (tabId: string, mode: SplitMode) => void; onAddToSplit: (tabId: string) => void; onRemoveFromSplit: (tabId: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void; onOpenFileManager?: (tabId: string) => void; isAppFullscreen: boolean; onToggleAppFullscreen: () => void; rightDockOpen?: boolean; onToggleRightDock?: () => void; showTabNumbers?: boolean; }) { const { t } = useTranslation(); const reduceMotion = useReducedMotion(); const [open, setOpen] = useState(true); const [dragTabId, setDragTabId] = useState(null); const [dragTargetIndex, setDragTargetIndex] = useState(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const [contextTabId, setContextTabId] = useState(null); const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>( null, ); const [renamingTabId, setRenamingTabId] = useState(null); const [renameValue, setRenameValue] = useState(""); const renameInputRef = useRef(null); const tabBarRef = useRef(null); const tabEls = useRef>(new Map()); const dragData = useRef<{ id: string; index: number; startX: number; startY: number; offsetX: number; width: number; barTop: number; barHeight: number; x: number; y: number; } | null>(null); const dragTargetRef = useRef(null); const didDrag = useRef(false); // Hand-rolled instead of a Framer layoutId: a shared layoutId matched // multiple tabs sharing the same indicator element across re-renders, and // reordering tabs (which doesn't change which tab is active) could make it // measure the wrong tab's rect or animate a spurious slide. Measuring the // active tab's own DOM node directly is always correct. const [indicatorRect, setIndicatorRect] = useState<{ left: number; width: number; } | null>(null); const skipIndicatorAnimRef = useRef(false); const isSplit = splitMode !== "none"; const paneCount = PANE_COUNTS[splitMode]; const measureIndicator = useCallback(() => { const el = activeTabId ? tabEls.current.get(activeTabId) : null; if (!el) { setIndicatorRect(null); return; } setIndicatorRect({ left: el.offsetLeft, width: el.offsetWidth }); }, [activeTabId]); // useLayoutEffect so the indicator is measured for the new tab order before // paint -- with useEffect there was a frame right after a drag-drop reorder // where the indicator (which stays hidden during the drag) reappeared at // its pre-reorder position before this caught up, flashing at the old spot. useLayoutEffect(() => { measureIndicator(); }, [measureIndicator, tabs, splitMode, dragTargetIndex]); useEffect(() => { const el = tabBarRef.current; if (!el || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measureIndicator); observer.observe(el); return () => observer.disconnect(); }, [measureIndicator]); useEffect(() => { if (!skipIndicatorAnimRef.current) return; const id = requestAnimationFrame(() => { skipIndicatorAnimRef.current = false; }); return () => cancelAnimationFrame(id); }, [indicatorRect]); useEffect(() => { const el = tabBarRef.current; if (!el) return; const handleWheel = (e: WheelEvent) => { if (e.deltaY !== 0) { e.preventDefault(); el.scrollLeft += e.deltaY; } }; el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); }, []); useEffect(() => { if (!dragTabId) return; function onPointerMove(e: PointerEvent) { if (!dragData.current || !tabBarRef.current) return; const d = dragData.current; if (Math.abs(e.clientX - d.startX) > 5) didDrag.current = true; const barRect = tabBarRef.current.getBoundingClientRect(); const x = Math.max( barRect.left + 2, Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX), ); const y = d.barTop; setDragPos({ x, y }); const centerX = e.clientX - d.offsetX + d.width / 2; let newTarget = d.index; tabEls.current.forEach((el, id) => { if (id === d.id) return; const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; const idx = tabs.findIndex((t) => t.id === id); if (idx < d.index && centerX < mid) newTarget = Math.min(newTarget, idx); if (idx > d.index && centerX > mid) newTarget = Math.max(newTarget, idx); }); if (tabs[0].type === "dashboard") newTarget = Math.max(1, newTarget); dragTargetRef.current = newTarget; setDragTargetIndex(newTarget); } function endDrag(commit: boolean) { if (!dragData.current) return; const { id, index } = dragData.current; const to = dragTargetRef.current ?? index; if (commit && to !== index) { const next = [...tabs]; if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]); skipIndicatorAnimRef.current = true; onReorderTabs(next); } dragData.current = null; dragTargetRef.current = null; setDragTabId(null); setDragTargetIndex(null); setDragPos(null); setTimeout(() => { didDrag.current = false; }, 0); } function onPointerUp() { endDrag(true); } // The browser can abort a pointer gesture without ever firing pointerup // (OS/browser cancels it, focus leaves the window, capture is lost some // other way). Without this, dragTabId stays stuck non-null forever and // every tab keeps rendering with a stale translateX from the aborted // drag, which can visually butt two tabs together with no seam between // them until something else forces a re-render. function onPointerCancel() { endDrag(false); } window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerCancel); window.addEventListener("lostpointercapture", onPointerCancel); return () => { window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("pointercancel", onPointerCancel); window.removeEventListener("lostpointercapture", onPointerCancel); }; }, [dragTabId, tabs, onReorderTabs]); useEffect(() => { if (!contextTabId) return; function onDown(e: MouseEvent) { if (!(e.target as HTMLElement).closest("[data-context-menu]")) { setContextTabId(null); setContextPos(null); } } window.addEventListener("mousedown", onDown); return () => window.removeEventListener("mousedown", onDown); }, [contextTabId]); useEffect(() => { if (renamingTabId) { setTimeout(() => renameInputRef.current?.focus(), 0); } }, [renamingTabId]); function commitRename() { if (!renamingTabId) return; const trimmed = renameValue.trim(); if (trimmed) onRenameTab?.(renamingTabId, trimmed); setRenamingTabId(null); } const dragIdx = tabs.findIndex((t) => t.id === dragTabId); const target = dragTargetIndex ?? dragIdx; return (
{indicatorRect && !dragTabId && ( )} {tabs.map((tab, index) => { const active = tab.id === activeTabId; const isDragging = dragTabId === tab.id; const paneIdx = paneTabIds.indexOf(tab.id); const isInPane = paneIdx !== -1; const isFocusedPane = isInPane && paneIdx === focusedPaneIndex; let translateX = 0; if ( dragTabId && !isDragging && dragIdx !== -1 && target !== null && target !== dragIdx ) { const draggedWidth = tabEls.current.get(dragTabId)?.offsetWidth ?? 0; if (dragIdx < target && index > dragIdx && index <= target) translateX = -draggedWidth; else if (dragIdx > target && index < dragIdx && index >= target) translateX = draggedWidth; } const showFocusIndicator = isFocusedPane && isSplit; const showInPaneIndicator = isInPane && isSplit && !isFocusedPane; return (
{ if (el) tabEls.current.set(tab.id, el); else tabEls.current.delete(tab.id); }} draggable={isSplit && tab.type !== "dashboard"} onDragStart={(e) => { if (!isSplit || tab.type === "dashboard") return; e.dataTransfer.setData("text/plain", tab.id); e.dataTransfer.effectAllowed = "move"; }} onClick={() => !dragTabId && !didDrag.current && onSetActiveTab(tab.id) } onMouseDown={(e) => { if (e.button === 1 && tab.type !== "dashboard") { e.preventDefault(); onCloseTab(tab.id); } }} onContextMenu={(e) => { if (tab.type === "dashboard") return; e.preventDefault(); setContextTabId(tab.id); setContextPos({ x: e.clientX, y: e.clientY }); }} onPointerDown={(e) => { if (e.button !== 0 || tab.type === "dashboard") return; e.preventDefault(); const el = tabEls.current.get(tab.id); if (!el || !tabBarRef.current) return; const rect = el.getBoundingClientRect(); const barRect = tabBarRef.current.getBoundingClientRect(); dragData.current = { id: tab.id, index, startX: e.clientX, startY: e.clientY, offsetX: e.clientX - rect.left, width: rect.width, barTop: barRect.top, barHeight: barRect.height, x: rect.left, y: barRect.top, }; setDragTabId(tab.id); setDragTargetIndex(index); setDragPos({ x: rect.left, y: barRect.top }); (e.currentTarget as HTMLElement).setPointerCapture( e.pointerId, ); }} style={{ // Only set a transform while a drag is actually shifting // tabs around. Leaving a permanent translateX(0) on every // tab gives each one its own transform node, which opts it // out of the pixel snapping a plain box gets when painted. // The 1px seam then lands on a fractional device pixel at // non-100% zoom and anti-aliases down to nothing, so two // tabs look merged into one. transform: dragTabId && !isDragging ? `translateX(${translateX}px)` : undefined, transition: dragTabId && !isDragging ? "transform 200ms ease" : "none", opacity: isDragging ? 0 : 1, cursor: tab.type === "dashboard" ? "pointer" : isDragging ? "grabbing" : "grab", userSelect: "none", }} className={`group/tab relative flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm ${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""} ${ tab.type === "dashboard" ? `px-2.5 md:px-3.5 ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}` : `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}` }`} > {/* Focused-pane indicator: brand accent bottom border overlay */} {showFocusIndicator && ( )} {/* In-pane (not focused) indicator: subtle dot */} {showInPaneIndicator && ( )} {showTabNumbers && tab.type !== "dashboard" && ( {tabs.slice(0, index).filter((t) => t.type !== "dashboard") .length + 1} )} {tabIcon(tab.type)} {tab.type !== "dashboard" && renamingTabId === tab.id ? ( setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === "Enter") commitRename(); else if (e.key === "Escape") setRenamingTabId(null); }} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} className="bg-transparent border-b border-accent-brand outline-none text-sm w-28 min-w-0" style={{ fontWeight: "inherit" }} /> ) : ( tab.type !== "dashboard" && tab.label )} {tab.type !== "dashboard" && renamingTabId !== tab.id && (
{isConnectionTab(tab.type) && ( )}
)}
); })} {dragTabId && dragPos && (() => { const tab = tabs.find((t) => t.id === dragTabId)!; const active = tab.id === activeTabId; const dragTabWidth = tabEls.current.get(dragTabId)?.offsetWidth; const dragTabHeight = tabEls.current.get(dragTabId)?.offsetHeight; return (
{tabIcon(tab.type)} {tab.type !== "dashboard" && tab.label}
); })()}
{tabs.map((tab, index) => (
onSetActiveTab(tab.id)} className={`flex items-center justify-between px-2 py-2 text-xs cursor-default hover:bg-accent hover:text-accent-foreground ${tab.id === activeTabId ? "text-foreground" : "text-muted-foreground"}`} >
{showTabNumbers && tab.type !== "dashboard" && ( {tabs .slice(0, index) .filter((t) => t.type !== "dashboard").length + 1} )} {tabIcon(tab.type)} {tab.type === "dashboard" ? t("nav.dashboard") : tab.label}
{tab.type !== "dashboard" && ( )}
))}
{onToggleRightDock && ( <> )} {!isElectron() && ( <> )}
{!open && ( )} {/* Right-click context menu */} {contextTabId && contextPos && (() => { const ctxTab = tabs.find((t) => t.id === contextTabId); if (!ctxTab) return null; const isInPane = paneTabIds.includes(contextTabId); const hasEmptySlot = isSplit && paneTabIds.slice(0, paneCount).some((p) => p === null); return (
{ctxTab.label}
{isConnectionTab(ctxTab.type) && ( )} {typeof ctxTab.terminalRef?.current?.openFileManager === "function" && ctxTab.host && onOpenFileManager && ( )} {ctxTab.type !== "split-screen" && ( <>
{t("terminal.split.splitTab")}
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => ( ))} )} {isSplit && ( <>
{isInPane ? ( ) : hasEmptySlot ? ( ) : null} )}
); })()}
); }