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; } 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. The drag is HTML5's, not a pointer-event * reimplementation, so the OS supplies the drag image and the Escape-to-cancel * behaviour for free; the only thing tracked here is where the drop would land. * `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); 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]); 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 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" : ""}`; const endDrag = () => { setDragKey(null); setDropIndex(null); }; /** * Drag props shared by both tab kinds. * * A tab in rename mode is not draggable: a `draggable` ancestor swallows the * mouse-drag that selects text inside the input, which would make the rename * field impossible to select in. */ const dragProps = (key: string, index: number, renaming: boolean) => ({ draggable: !renaming, onDragStart: (e: React.DragEvent) => { e.dataTransfer.effectAllowed = "move"; // Some platforms refuse to start a drag with an empty payload. e.dataTransfer.setData("text/plain", key); setDragKey(key); setDropIndex(index); }, onDragOver: (e: React.DragEvent) => { if (!dragKey) return; // not our drag — a file dropped on the strip isn't one e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientX > rect.left + rect.width / 2; setDropIndex(index + (after ? 1 : 0)); }, onDragEnd: endDrag, }); /** Drop lands wherever the marker is showing, and only there. */ const onDrop = (e: React.DragEvent) => { const key = dragKey ?? e.dataTransfer.getData("text/plain"); if (!key || dropIndex === null) return endDrag(); e.preventDefault(); const from = tabOrder.indexOf(key); // `dropIndex` is a slot in the strip as it looks *now*; `moveTab` places the // tab after pulling it out, so every slot past the tab's own shifts down one. moveTab(key, dropIndex > from ? dropIndex - 1 : dropIndex); endDrag(); }; const dropMarker = (