| 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"; |
|
|
| |
| export const MAX_PANES_PER_TAB = 8; |
|
|
| export type TerminalTab = { |
| id: number; |
| kind: "terminal"; |
| title: string; |
| cwd?: string; |
| paneTree: PaneNode; |
| activeLeafId: number; |
| |
| private?: boolean; |
| }; |
|
|
| export type EditorTab = { |
| id: number; |
| kind: "editor"; |
| title: string; |
| path: string; |
| dirty: boolean; |
| |
| |
| |
| |
| |
| 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; |
| |
| originalContent: string; |
| proposedContent: string; |
| |
| 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<TerminalTab>) { |
| const [tabs, setTabs] = useState<Tab[]>(() => { |
| 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; |
| }, []); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const openFileTab = useCallback((path: string, pin = true) => { |
| let targetId: number | null = null; |
| setTabs((curr) => { |
| if (pin) { |
| |
| 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 { |
| |
| const persistent = curr.find( |
| (t) => t.kind === "editor" && t.path === path && !(t as EditorTab).preview, |
| ); |
| if (persistent) { |
| targetId = persistent.id; |
| return curr; |
| } |
| |
| const existingPreview = curr.find( |
| (t) => t.kind === "editor" && t.path === path && (t as EditorTab).preview, |
| ); |
| if (existingPreview) { |
| targetId = existingPreview.id; |
| return curr; |
| } |
| |
| 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; |
| }, []); |
|
|
| |
| |
| |
| |
| 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), |
| }), |
| }; |
| } |
| |
| 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], |
| ); |
|
|
| |
| 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 }; |
| }), |
| ); |
| }, |
| [], |
| ); |
|
|
| |
| 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, |
| }; |
| } |
|
|