import { useCallback, useRef, useState } from "react"; import { hasLeaf, leafIds, nextLeafId, removeLeaf, setLeafCwd as setLeafCwdInTree, siblingLeafOf, splitLeaf, type PaneNode, type SplitDir, } from "@/modules/terminal/lib/panes"; // Browsers cap WebGL contexts at ~16; one xterm renderer per leaf. export const MAX_PANES_PER_TAB = 8; export type TerminalTab = { id: number; kind: "terminal"; title: string; cwd?: string; paneTree: PaneNode; activeLeafId: number; /** AI agent cannot read buffer / context of this terminal. */ private?: boolean; }; export type EditorTab = { id: number; kind: "editor"; title: string; path: string; dirty: boolean; /** * True while the tab is in the transient "preview" state — opened by a * single-click in the explorer and not yet pinned by the user. A preview tab * is replaced by the next single-click rather than accumulating. */ preview: boolean; }; export type PreviewTab = { id: number; kind: "preview"; title: string; url: string; }; export type AiDiffStatus = "pending" | "approved" | "rejected"; export type AiDiffTab = { id: number; kind: "ai-diff"; title: string; path: string; /** "" for newly created files. */ originalContent: string; proposedContent: string; /** Tool-call approval id used to resolve the AI SDK approval. */ approvalId: string; status: AiDiffStatus; isNewFile: boolean; }; export type Tab = TerminalTab | EditorTab | PreviewTab | AiDiffTab; export type TabPatch = Partial<{ title: string; cwd: string; path: string; dirty: boolean; url: string; }>; function basename(path: string): string { const parts = path.split(/[\\/]/).filter(Boolean); return parts.length ? parts[parts.length - 1] : path; } function titleFromUrl(url: string): string { try { const u = new URL(url); return u.host || url; } catch { return url || "preview"; } } export function useTabs(initial?: Partial) { const [tabs, setTabs] = useState(() => { const tabId = 1; const leafId = 2; return [ { id: tabId, kind: "terminal", title: initial?.title ?? "shell", cwd: initial?.cwd, paneTree: { kind: "leaf", id: leafId, cwd: initial?.cwd }, activeLeafId: leafId, }, ]; }); const [activeId, setActiveId] = useState(1); const nextIdRef = useRef(3); const newTab = useCallback((cwd?: string) => { const tabId = nextIdRef.current++; const leafId = nextIdRef.current++; setTabs((t) => [ ...t, { id: tabId, kind: "terminal", title: "shell", cwd, paneTree: { kind: "leaf", id: leafId, cwd }, activeLeafId: leafId, }, ]); setActiveId(tabId); return tabId; }, []); const newPrivateTab = useCallback((cwd?: string) => { const tabId = nextIdRef.current++; const leafId = nextIdRef.current++; setTabs((t) => [ ...t, { id: tabId, kind: "terminal", title: "private", cwd, paneTree: { kind: "leaf", id: leafId, cwd }, activeLeafId: leafId, private: true, }, ]); setActiveId(tabId); return tabId; }, []); /** * Opens a file in an editor tab. * * - `pin = true` (default) — opens or activates a **persistent** tab. * If the path is currently in the preview slot it is promoted in-place. * Use this for programmatic opens (AI diff, New File dialog, etc.). * - `pin = false` — VSCode-style **preview** tab. A single shared slot is * reused: if a persistent tab for the path already exists it is activated; * otherwise the current preview slot is replaced with the new path. */ const openFileTab = useCallback((path: string, pin = true) => { let targetId: number | null = null; setTabs((curr) => { if (pin) { // Persistent open: find any existing editor tab, pin it if needed. const existing = curr.find( (t) => t.kind === "editor" && t.path === path, ); if (existing) { targetId = existing.id; if ((existing as EditorTab).preview) { return curr.map((t) => t.id === existing.id ? { ...t, preview: false } : t, ); } return curr; } const id = nextIdRef.current++; targetId = id; return [ ...curr, { id, kind: "editor", title: basename(path), path, dirty: false, preview: false, } satisfies EditorTab, ]; } else { // Preview open: persistent tab for this path takes priority. const persistent = curr.find( (t) => t.kind === "editor" && t.path === path && !(t as EditorTab).preview, ); if (persistent) { targetId = persistent.id; return curr; } // Reuse the slot if it already shows the same path. const existingPreview = curr.find( (t) => t.kind === "editor" && t.path === path && (t as EditorTab).preview, ); if (existingPreview) { targetId = existingPreview.id; return curr; } // Replace the current preview slot, or append a new one. const previewIdx = curr.findIndex( (t) => t.kind === "editor" && (t as EditorTab).preview, ); const id = nextIdRef.current++; targetId = id; const tab: EditorTab = { id, kind: "editor", title: basename(path), path, dirty: false, preview: true, }; if (previewIdx === -1) return [...curr, tab]; const next = [...curr]; next[previewIdx] = tab; return next; } }); if (targetId !== null) setActiveId(targetId); return targetId as number | null; }, []); /** * Promotes a preview tab to a persistent one. Called on double-click of the * tab title in the tab bar. Dirty edits also auto-promote (see `updateTab`). */ const pinTab = useCallback((id: number) => { setTabs((curr) => curr.map((t) => t.id === id && t.kind === "editor" ? { ...t, preview: false } : t, ), ); }, []); const openAiDiffTab = useCallback( (input: { path: string; originalContent: string; proposedContent: string; approvalId: string; isNewFile: boolean; }) => { let targetId: number | null = null; setTabs((curr) => { const existing = curr.find( (t) => t.kind === "ai-diff" && t.approvalId === input.approvalId, ); if (existing) { targetId = existing.id; return curr; } const id = nextIdRef.current++; targetId = id; const title = `${basename(input.path)} (AI diff)`; return [ ...curr, { id, kind: "ai-diff", title, path: input.path, originalContent: input.originalContent, proposedContent: input.proposedContent, approvalId: input.approvalId, status: "pending", isNewFile: input.isNewFile, }, ]; }); if (targetId !== null) setActiveId(targetId); return targetId as number | null; }, [], ); const setAiDiffStatus = useCallback( (approvalId: string, status: AiDiffStatus) => { setTabs((curr) => curr.map((t) => t.kind === "ai-diff" && t.approvalId === approvalId ? { ...t, status } : t, ), ); }, [], ); const newPreviewTab = useCallback((url: string) => { const id = nextIdRef.current++; setTabs((t) => [ ...t, { id, kind: "preview", title: titleFromUrl(url), url }, ]); setActiveId(id); return id; }, []); const closeTab = useCallback((id: number) => { setTabs((curr) => { if (curr.length <= 1) return curr; const idx = curr.findIndex((t) => t.id === id); const next = curr.filter((t) => t.id !== id); setActiveId((active) => id === active ? next[Math.max(0, idx - 1)].id : active, ); return next; }); }, []); const updateTab = useCallback((id: number, patch: TabPatch) => { setTabs((t) => t.map((x) => { if (x.id !== id) return x; if (x.kind === "terminal") { return { ...x, ...(patch.title !== undefined && { title: patch.title }), ...(patch.cwd !== undefined && { cwd: patch.cwd }), }; } if (x.kind === "preview") { return { ...x, ...(patch.title !== undefined && { title: patch.title }), ...(patch.url !== undefined && { url: patch.url, title: patch.title ?? titleFromUrl(patch.url), }), }; } // editor tab: auto-promote from preview the moment the file becomes dirty. const autoPin = patch.dirty === true && (x as EditorTab).preview ? { preview: false } : {}; return { ...x, ...autoPin, ...(patch.title !== undefined && { title: patch.title }), ...(patch.dirty !== undefined && { dirty: patch.dirty }), ...(patch.path !== undefined && { path: patch.path }), }; }), ); }, []); const selectByIndex = useCallback( (idx: number) => { const t = tabs[idx]; if (t) setActiveId(t.id); }, [tabs], ); /** Update a leaf's cwd; mirror to the tab's `cwd` when the leaf is active. */ const setLeafCwd = useCallback((leafId: number, cwd: string) => { setTabs((curr) => curr.map((t) => { if (t.kind !== "terminal") return t; if (!hasLeaf(t.paneTree, leafId)) return t; const paneTree = setLeafCwdInTree(t.paneTree, leafId, cwd); const isActive = t.activeLeafId === leafId; return { ...t, paneTree, ...(isActive && { cwd }) }; }), ); }, []); const focusPane = useCallback((tabId: number, leafId: number) => { setTabs((curr) => curr.map((t) => { if (t.id !== tabId || t.kind !== "terminal") return t; if (!hasLeaf(t.paneTree, leafId)) return t; if (t.activeLeafId === leafId) return t; return { ...t, activeLeafId: leafId }; }), ); }, []); const focusNextPaneInTab = useCallback( (tabId: number, delta: 1 | -1) => { setTabs((curr) => curr.map((t) => { if (t.id !== tabId || t.kind !== "terminal") return t; const next = nextLeafId(t.paneTree, t.activeLeafId, delta); if (next === t.activeLeafId) return t; return { ...t, activeLeafId: next }; }), ); }, [], ); /** Split the active leaf of `tabId` along `dir`. Returns the new leaf id. */ const splitActivePane = useCallback( (tabId: number, dir: SplitDir): number | null => { let newLeafId: number | null = null; setTabs((curr) => curr.map((t) => { if (t.id !== tabId || t.kind !== "terminal") return t; if (leafIds(t.paneTree).length >= MAX_PANES_PER_TAB) return t; const splitId = nextIdRef.current++; const leafId = nextIdRef.current++; newLeafId = leafId; const paneTree = splitLeaf( t.paneTree, t.activeLeafId, splitId, leafId, dir, t.cwd, ); return { ...t, paneTree, activeLeafId: leafId }; }), ); return newLeafId; }, [], ); const closePaneByLeaf = useCallback((leafId: number): void => { setTabs((curr) => { const tab = curr.find( (t) => t.kind === "terminal" && hasLeaf(t.paneTree, leafId), ); if (!tab || tab.kind !== "terminal") return curr; const newTree = removeLeaf(tab.paneTree, leafId); if (newTree === null) { if (curr.length <= 1) return curr; const idx = curr.findIndex((x) => x.id === tab.id); const next = curr.filter((x) => x.id !== tab.id); setActiveId((active) => active === tab.id ? next[Math.max(0, idx - 1)].id : active, ); return next; } const remaining = leafIds(newTree); let newActive = tab.activeLeafId; if (tab.activeLeafId === leafId) { const sib = siblingLeafOf(tab.paneTree, leafId); newActive = sib && remaining.includes(sib) ? sib : remaining[0]; } return curr.map((x) => x.id === tab.id ? { ...x, paneTree: newTree, activeLeafId: newActive } : x, ); }); }, []); const closeActivePane = useCallback((tabId: number): boolean => { let closedTab = false; setTabs((curr) => { const t = curr.find((x) => x.id === tabId); if (!t || t.kind !== "terminal") return curr; const target = t.activeLeafId; const newTree = removeLeaf(t.paneTree, target); if (newTree === null) { if (curr.length <= 1) return curr; const idx = curr.findIndex((x) => x.id === tabId); const next = curr.filter((x) => x.id !== tabId); setActiveId((active) => active === tabId ? next[Math.max(0, idx - 1)].id : active, ); closedTab = true; return next; } const remaining = leafIds(newTree); const sib = siblingLeafOf(t.paneTree, target); const newActive = sib && remaining.includes(sib) ? sib : remaining[0]; return curr.map((x) => x.id === tabId ? { ...x, paneTree: newTree, activeLeafId: newActive } : x, ); }); return closedTab; }, []); return { tabs, activeId, setActiveId, newTab, newPrivateTab, openFileTab, pinTab, newPreviewTab, openAiDiffTab, setAiDiffStatus, closeTab, updateTab, selectByIndex, setLeafCwd, focusPane, focusNextPaneInTab, splitActivePane, closeActivePane, closePaneByLeaf, }; }