import { useRef, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; 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, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; import { isElectron } from "@/lib/electron"; import type { Tab, TabType, SplitMode } from "@/types/ui-types"; import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme"; const CONNECTION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; export function TabBar({ tabs, activeTabId, splitMode, paneTabIds, focusedPaneIndex, onSetActiveTab, onCloseTab, onRefreshTab, onReorderTabs, onSplitTab, onAddToSplit, onRemoveFromSplit, onRenameTab, onOpenFileManager, isAppFullscreen, onToggleAppFullscreen, }: { 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; }) { const { t } = useTranslation(); 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); const isSplit = splitMode !== "none"; const paneCount = PANE_COUNTS[splitMode]; 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 onPointerUp() { if (!dragData.current) return; const { id, index } = dragData.current; const to = dragTargetRef.current ?? index; if (to !== index) { const next = [...tabs]; if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]); onReorderTabs(next); } dragData.current = null; dragTargetRef.current = null; setDragTabId(null); setDragTargetIndex(null); setDragPos(null); setTimeout(() => { didDrag.current = false; }, 0); } window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); return () => { window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); }; }, [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 (
{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={{ transform: isDragging ? "none" : `translateX(${translateX}px)`, 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 ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}` : `px-2.5 md:px-4 font-medium ${active ? "border-b-2 border-b-accent-brand 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 && ( )} {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 && (
{CONNECTION_TAB_TYPES.includes(tab.type) && ( )}
)}
); })} {dragTabId && dragPos && (() => { const tab = tabs.find((t) => t.id === dragTabId)!; const active = tab.id === activeTabId; return (
{tabIcon(tab.type)} {tab.type !== "dashboard" && tab.label}
); })()}
{tabs.map((tab) => (
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"}`} >
{tabIcon(tab.type)} {tab.type === "dashboard" ? t("nav.dashboard") : tab.label}
{tab.type !== "dashboard" && ( )}
))}
{!isElectron() && ( <> )}
{!open && ( )} {/* Right-click context menu */} {contextTabId && contextPos && (() => { const ctxTab = tabs.find((t) => t.id === contextTabId); if (!ctxTab) return null; const isInPane = paneTabIds.indexOf(contextTabId) !== -1; const hasEmptySlot = isSplit && paneTabIds.slice(0, paneCount).some((p) => p === null); return (
{ctxTab.label}
{CONNECTION_TAB_TYPES.includes(ctxTab.type) && ( )} {ctxTab.type === "terminal" && ctxTab.host && onOpenFileManager && ( )}
{/* Split submenu */}
{t("terminal.split.splitTab")}
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => ( ))} {isSplit && ( <>
{isInPane ? ( ) : hasEmptySlot ? ( ) : null} )}
); })()}
); }