import { Fragment, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useTerminal } from "../../hooks/useTerminal"; import { useProjects } from "../../hooks/useProjects"; import { useAppState, isHomeTab, tabKeyId, terminalTabKey, } from "../../store/appState"; import { effectivePermissionMode } from "../projects/PermissionModeControl"; import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import type { PermissionMode } from "../../lib/types"; interface ContextMenuState { sessionId: string; x: number; y: number; } /** Pixels of horizontal travel before a press becomes a drag rather than a click. */ const DRAG_THRESHOLD = 4; const MODE_BADGE: Record = { plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" }, }; /** * One strip for both main-area tab kinds: Project Home views (⌂) and * terminals (▣). * * Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see * `pointerProps` for why neither of the two obvious alternatives works. * `Ctrl+Shift+←/→` does the same thing without a mouse. */ export default function MainTabs() { const { sessions, close } = useTerminal(); const { projects, update } = useProjects(); const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState( useShallow((s) => ({ tabOrder: s.tabOrder, activeTabKey: s.activeTabKey, setActiveTabKey: s.setActiveTabKey, closeHomeTab: s.closeHomeTab, moveTab: s.moveTab, })), ); const [menu, setMenu] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const renameInputRef = useRef(null); /** The tab being dragged, and the slot it would drop into. */ const [dragKey, setDragKey] = useState(null); const [dropIndex, setDropIndex] = useState(null); /** Where the dragged tab is drawn, and how it looked when the drag started. */ const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>( null, ); const stripRef = useRef(null); /** A press that has not yet moved far enough to be a drag. */ const pending = useRef<{ key: string; startX: number; dragging: boolean; offsetX: number; width: number; height: number; top: number; } | null>(null); const suppressClick = useRef(false); useEffect(() => { if (!menu) return; const dismiss = () => setMenu(null); window.addEventListener("click", dismiss); window.addEventListener("scroll", dismiss, true); return () => { window.removeEventListener("click", dismiss); window.removeEventListener("scroll", dismiss, true); }; }, [menu]); useEffect(() => { if (renamingId) { renameInputRef.current?.focus(); renameInputRef.current?.select(); } }, [renamingId]); // Escape abandons a drag — the one affordance a pointer-event drag has to // supply for itself, since the OS is not running this one. useEffect(() => { if (!dragKey) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; pending.current = null; setDragKey(null); setDropIndex(null); setGhost(null); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [dragKey]); if (tabOrder.length === 0) { return (
No open tabs — select a project to open its home view.
); } const getCustomName = (projectId: string, sessionId: string): string | null => { const project = projects.find((p) => p.id === projectId); return project?.renamed_session_names?.[sessionId] ?? null; }; const startRename = (sessionId: string) => { const session = sessions.find((s) => s.id === sessionId); if (!session) return; const current = getCustomName(session.projectId, sessionId) ?? session.sessionName ?? session.projectName; setRenameDraft(current); setRenamingId(sessionId); setMenu(null); }; const commitRename = async (sessionId: string) => { const session = sessions.find((s) => s.id === sessionId); if (!session) { setRenamingId(null); return; } const project = projects.find((p) => p.id === session.projectId); if (!project) { setRenamingId(null); return; } const trimmed = renameDraft.trim(); const map = { ...(project.renamed_session_names ?? {}) }; if (trimmed) { map[sessionId] = trimmed; } else { delete map[sessionId]; } try { await update({ ...project, renamed_session_names: map }); } catch (err) { console.error("Failed to rename terminal tab:", err); } finally { setRenamingId(null); } }; const clearCustomName = async (sessionId: string) => { const session = sessions.find((s) => s.id === sessionId); if (!session) return; const project = projects.find((p) => p.id === session.projectId); if (!project) return; const map = { ...(project.renamed_session_names ?? {}) }; if (!(sessionId in map)) { setMenu(null); return; } delete map[sessionId]; try { await update({ ...project, renamed_session_names: map }); } catch (err) { console.error("Failed to reset terminal tab name:", err); } finally { setMenu(null); } }; const tabClass = (active: boolean, dragging: boolean) => `flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${ active ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" }${dragging ? " opacity-40" : ""}`; /** * What a tab reads as, for the dragged copy. Same sources the tab itself * uses — a ghost showing a different name from the tab it came from would be * worse than no ghost. */ const tabLabel = (key: string): string => { if (isHomeTab(key)) { return projects.find((p) => p.id === tabKeyId(key))?.name ?? ""; } const session = sessions.find((s) => s.id === tabKeyId(key)); if (!session) return ""; const custom = getCustomName(session.projectId, session.id); return custom ? `${session.projectName}: ${custom}` : (session.sessionName ?? session.projectName) + (session.sessionType === "bash" ? " (bash)" : ""); }; const endDrag = () => { pending.current = null; setDragKey(null); setDropIndex(null); setGhost(null); }; /** * Which slot the pointer is currently over, as an insertion index into * `tabOrder`. * * Measured from the tabs actually on screen rather than from the event's * target, so the answer is the same whatever the pointer happens to be over — * including the drop marker itself, and including a `tabOrder` entry whose * session has already gone and which therefore renders nothing. */ const dropIndexAt = (clientX: number): number => { const strip = stripRef.current; if (!strip) return tabOrder.length; for (const el of strip.querySelectorAll("[data-tab-index]")) { const rect = el.getBoundingClientRect(); if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex); } return tabOrder.length; }; /** * Dragging is done with pointer events, not HTML5 drag-and-drop. * * Two reasons, both load-bearing. Tauri's `dragDropEnabled` — which the * terminal needs left on, because only the native drag-drop event carries * dropped *file paths* — blocks HTML5 drag inside the webview on Windows, so * an HTML5 implementation is simply dead there. And an HTML5 drag carries a * `DataTransfer`: released over any text field in the app, the default * handler types the payload into it. */ const pointerProps = (key: string, renaming: boolean) => ({ onPointerDown: (e: React.PointerEvent) => { // Left button only, never from the close button, and never while the // rename input is up — that drag is a text selection. if (e.button !== 0 || renaming) return; if ((e.target as HTMLElement).closest("button, input")) return; const rect = e.currentTarget.getBoundingClientRect(); pending.current = { key, startX: e.clientX, dragging: false, // Where inside the tab the pointer grabbed it, so the ghost sits under // the cursor exactly where the real tab was — the thing that makes a // drag feel like moving an object rather than nudging a setting. offsetX: e.clientX - rect.left, width: rect.width, height: rect.height, top: rect.top, }; e.currentTarget.setPointerCapture?.(e.pointerId); }, onPointerMove: (e: React.PointerEvent) => { const drag = pending.current; if (!drag) return; // A few pixels of slop, so a click that trembles stays a click. if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return; drag.dragging = true; setDragKey(drag.key); setDropIndex(dropIndexAt(e.clientX)); setGhost({ x: e.clientX - drag.offsetX, y: drag.top, label: tabLabel(drag.key), icon: isHomeTab(drag.key) ? "⌂" : "▣", }); }, onPointerUp: (e: React.PointerEvent) => { const drag = pending.current; e.currentTarget.releasePointerCapture?.(e.pointerId); if (!drag?.dragging) { pending.current = null; return; // a plain click: leave it to `onClick` to select the tab } const to = dropIndexAt(e.clientX); const from = tabOrder.indexOf(drag.key); // `to` is a slot in the strip as it looks *now*; `moveTab` places the tab // after pulling it out, so every slot past its own shifts down one. if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to); // The click that follows this pointerup is the drag's, not a selection. suppressClick.current = true; endDrag(); }, onPointerCancel: endDrag, }); /** A drag in progress swallows the click it ends with. */ const activateTab = (key: string) => { if (suppressClick.current) { suppressClick.current = false; return; } setActiveTabKey(key); }; const dropMarker = (