import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { del, get, post, put } from "../api"; import { confirmDialog, toast } from "../dialogs"; import { useT } from "../i18n"; import { Icon } from "./Icon"; import type { TermInfo } from "./Terminal"; // xterm and the editor are large: load them only when such a tab is shown. const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView }))); const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files }))); const POLL_MS = 15_000; const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`; /** A file manager tab (stored per user and chat on the server). */ interface FileTab { id: string; dir: string; } /** * Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the * server, so they show up again after a reload or on another device (the list is * refreshed periodically); which tab is active is per browser tab. */ export function useChatTabs(sessionId: string, cwd: string) { const t = useT(); const [terms, setTerms] = useState([]); const [files, setFilesState] = useState([]); // Local file tab changes not yet saved: polling must not undo them. const pending = useRef | undefined>(undefined); const [tab, setTabState] = useState(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); const setTab = useCallback( (v: string) => { setTabState(v); sessionStorage.setItem(tabKey(sessionId), v); }, [sessionId], ); const setFiles = useCallback( (update: (f: FileTab[]) => FileTab[], delay = 0) => setFilesState((cur) => { const next = update(cur); clearTimeout(pending.current); pending.current = setTimeout(() => { pending.current = undefined; void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e)); }, delay); return next; }), [sessionId], ); const reload = useCallback( () => get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`) .then((r) => { setTerms(r.terminals); if (!pending.current) setFilesState(r.files); }) .catch(() => undefined), [sessionId], ); useEffect(() => { setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); setFilesState([]); setTerms([]); void reload(); const id = setInterval(reload, POLL_MS); const onFocus = () => void reload(); window.addEventListener("focus", onFocus); return () => { clearInterval(id); window.removeEventListener("focus", onFocus); }; }, [sessionId, reload]); // A tab closed elsewhere (or a stale stored tab) falls back to the chat. const activeTerm = terms.find((x) => x.id === tab); const activeFiles = files.find((f) => f.id === tab); const current = activeTerm || activeFiles ? tab : "chat"; const openTerminal = async () => { try { const info = await post("/terminals", { session_id: sessionId, cols: 100, rows: 30 }); await reload(); setTab(info.id); } catch (e) { toast(e); } }; const closeTerminal = async (x: TermInfo, n: number) => { if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return; await del(`/terminals/${x.id}`).catch(() => undefined); if (tab === x.id) setTab("chat"); await reload(); }; const openFiles = () => { const id = `files:${Date.now().toString(36)}`; setFiles((f) => [...f, { id, dir: cwd }]); setTab(id); }; const closeFiles = (id: string) => { setFiles((f) => f.filter((x) => x.id !== id)); if (tab === id) setTab("chat"); }; // Browsing saves the folder after a short pause, not on every click. const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]); return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged }; } type Tabs = ReturnType; const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/"; /** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */ export function ChatTabBar({ state }: { state: Tabs }) { const t = useT(); const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state; if (terms.length === 0 && files.length === 0) return null; return (
{terms.map((x, i) => ( ))} {files.map((f) => ( ))}
); } /** * Tab contents besides the chat. The active terminal is mounted (it re-attaches and * replays its output); file managers stay mounted while hidden, so unsaved edits survive. */ export function ChatTabPanes({ state }: { state: Tabs }) { const t = useT(); const { tab, activeTerm, files, fileDirChanged } = state; return ( {t("common.loading")}}> {activeTerm && } {files.map((f) => ( ))} ); }