diff --git a/README.md b/README.md index 15ac12d..6f06417 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+Shift+W` | Close active pane | | `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) | +**Tabs** + +| Key | Action | +|---|---| +| `Ctrl+T` | New tab (blank workspace, one pane) | +| `Ctrl+Shift+T` | Close current tab (confirms when the tab has live panes) | +| `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab | +| `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 | + **Navigation** | Key | Action | @@ -79,6 +88,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click. - **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard. - **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch. +- **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json. - **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. diff --git a/src/App.tsx b/src/App.tsx index 276bd4b..f552e49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { type Orientation, type LeafNode, type LeafShellSpec, + type Workspace, newLeaf, newId, splitLeaf, @@ -59,7 +60,9 @@ import { MIN_PANE_PX, type Direction, serialize, - deserialize, + serializeWorkspaces, + deserializeWorkspaces, + singletonEnvelope, presetSingle, presetTwoColumns, presetThreeColumns, @@ -75,6 +78,7 @@ import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; +import TabStrip from "./components/TabStrip"; import "./App.css"; import "./lib/layout/Gutter.css"; @@ -114,8 +118,86 @@ function describeSpec(spec: SpawnSpec): string { export default function App() { // ---- top-level state ----------------------------------------------------- - const [tree, setTree] = useState(() => newLeaf()); - const [activeLeafId, setActiveLeafId] = useState(null); + // Workspaces (tabs). Each is one independent tile tree. The mount effect + // seeds an initial singleton workspace if the persisted envelope is empty. + const [workspaces, setWorkspaces] = useState(() => { + const t = newLeaf(); + return [{ id: newId(), name: "Default", tree: t }]; + }); + const [currentWorkspaceId, setCurrentWorkspaceId] = useState( + () => "", // filled on mount alongside workspaces + ); + /** Per-workspace remembered active leaf — each tab keeps its own focus + * cursor so switching back doesn't dump the user into a random pane. */ + const [activeLeafByWorkspace, setActiveLeafByWorkspace] = useState< + Map + >(() => new Map()); + + // ---- workspace-aware tree wrappers -------------------------------------- + // These mirror the v0.3.0 single-tree API (`tree`, `setTree(updater)`, + // `activeLeafId`, `setActiveLeafId`) but operate on whichever workspace is + // current. Keeps the bulk of App.tsx unchanged across the tabs refactor. + // + // Refs first so the setter useCallbacks below capture stable references + // and don't need currentWorkspaceId in their dep arrays. + const currentWorkspaceIdRef = useRef(currentWorkspaceId); + const workspacesRef = useRef(workspaces); + + const currentWorkspace = useMemo( + () => workspaces.find((w) => w.id === currentWorkspaceId) ?? null, + [workspaces, currentWorkspaceId], + ); + const tree: TreeNode = currentWorkspace?.tree ?? workspaces[0]?.tree ?? newLeaf(); + + // Identity-stable across renders — both read the live currentWorkspaceId + // from a ref so every useCallback that captures these doesn't need to + // list them in its deps (mirroring the v0.3.0 stable-setter contract). + // The ref is updated in the workspacesRef / currentWorkspaceIdRef effects + // below; it's seeded synchronously by useState in the same render so + // first-render calls see the initial id. + const setTree = useCallback>>( + (updater) => { + setWorkspaces((ws) => { + if (ws.length === 0) return ws; + const targetId = currentWorkspaceIdRef.current || ws[0].id; + return ws.map((w) => { + if (w.id !== targetId) return w; + const next = + typeof updater === "function" + ? (updater as (prev: TreeNode) => TreeNode)(w.tree) + : updater; + if (next === w.tree) return w; + return { ...w, tree: next }; + }); + }); + }, + [], + ); + + const activeLeafId: NodeId | null = + (currentWorkspaceId && activeLeafByWorkspace.get(currentWorkspaceId)) ?? null; + + const setActiveLeafId = useCallback< + React.Dispatch> + >( + (updater) => { + setActiveLeafByWorkspace((prev) => { + const wsId = currentWorkspaceIdRef.current; + if (!wsId) return prev; + const cur = prev.get(wsId) ?? null; + const next = + typeof updater === "function" + ? (updater as (prev: NodeId | null) => NodeId | null)(cur) + : updater; + if (next === cur) return prev; + const m = new Map(prev); + m.set(wsId, next); + return m; + }); + }, + [], + ); + const [distros, setDistros] = useState([]); const [defaultShell, setDefaultShell] = useState({ shellKind: "wsl", @@ -142,24 +224,34 @@ export default function App() { useEffect(() => { treeRef.current = tree; }, [tree]); + // workspacesRef + currentWorkspaceIdRef are declared up by the tree + // wrappers so setTree / setActiveLeafId can capture them; sync here. + useEffect(() => { + workspacesRef.current = workspaces; + }, [workspaces]); + useEffect(() => { + currentWorkspaceIdRef.current = currentWorkspaceId; + }, [currentWorkspaceId]); // ---- mount: load workspace + distros + hosts ---------------------------- useEffect(() => { let cancelled = false; (async () => { - let loaded: TreeNode | null = null; + let loadedEnvelope: ReturnType = null; try { const json = await loadWorkspace(); - if (json) loaded = deserialize(json); + if (json) loadedEnvelope = deserializeWorkspaces(json); } catch (e) { console.warn("loadWorkspace failed:", e); } - if (!loaded) { + if (!loadedEnvelope) { try { const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); if (legacy) { - loaded = deserialize(legacy); - if (loaded) void saveWorkspace(legacy); + loadedEnvelope = deserializeWorkspaces(legacy); + if (loadedEnvelope) { + void saveWorkspace(serializeWorkspaces(loadedEnvelope)); + } localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch (e) { @@ -190,14 +282,20 @@ export default function App() { })(); if (cancelled) return; - if (loaded) { - if (initialDefault.shellKind === "wsl" && initialDefault.distro) { - backfillWslDistro(loaded, initialDefault.distro); - } - setTree(loaded); - } else { - setTree(newLeaf(defaultShellAsLeafProps(initialDefault))); + + let envelope = loadedEnvelope; + if (!envelope) { + envelope = singletonEnvelope( + newLeaf(defaultShellAsLeafProps(initialDefault)), + ); } + if (initialDefault.shellKind === "wsl" && initialDefault.distro) { + for (const w of envelope.workspaces) { + backfillWslDistro(w.tree, initialDefault.distro); + } + } + setWorkspaces(envelope.workspaces); + setCurrentWorkspaceId(envelope.workspaces[0].id); setDistros(resolvedDistros); setHosts(resolvedHosts); setDefaultShell(initialDefault); @@ -212,27 +310,34 @@ export default function App() { useEffect(() => { if (!ready) return; const id = window.setTimeout(() => { - saveWorkspace(serialize(tree)).catch((e) => - console.warn("saveWorkspace failed:", e), - ); + saveWorkspace( + serializeWorkspaces({ version: 2, workspaces }), + ).catch((e) => console.warn("saveWorkspace failed:", e)); }, SAVE_DEBOUNCE_MS); return () => clearTimeout(id); - }, [tree, ready]); + }, [workspaces, ready]); // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- + // Scoped to the current workspace layer so a hidden tab's lingering + // textarea focus (visibility:hidden doesn't auto-blur on tab switch) + // doesn't yank activeLeafId into a tab the user can't see. useEffect(() => { let lastLeafId: string | null = null; const interval = window.setInterval(() => { const el = document.activeElement; const leafEl = el?.closest("[data-leaf-id]"); - const id = leafEl?.getAttribute("data-leaf-id") ?? null; + if (!leafEl) return; + const wsEl = leafEl.closest("[data-workspace-id]"); + const wsId = wsEl?.getAttribute("data-workspace-id") ?? null; + if (wsId !== currentWorkspaceIdRef.current) return; + const id = leafEl.getAttribute("data-leaf-id"); if (id && id !== lastLeafId) { lastLeafId = id; setActiveLeafId(id); } }, 250); return () => clearInterval(interval); - }, []); + }, [setActiveLeafId]); // notify is defined up here (and not next to dismissNotification) because // the split callback below uses it to warn when a pane is too small to @@ -364,6 +469,95 @@ export default function App() { setTree((t) => setLeafShell(t, leafId, spec)); }, []); + // ---- tab (workspace) operations ---------------------------------------- + /** Append a fresh blank workspace using the current default shell, then + * switch to it. */ + const createTab = useCallback(() => { + const idx = workspacesRef.current.length + 1; + const w: Workspace = { + id: newId(), + name: `Tab ${idx}`, + tree: newLeaf(defaultShellAsLeafProps(defaultShell)), + }; + setWorkspaces((ws) => [...ws, w]); + setCurrentWorkspaceId(w.id); + }, [defaultShell]); + + /** Switch the visible workspace. No-op if the id isn't in the list. */ + const switchTab = useCallback((id: NodeId) => { + if (!workspacesRef.current.some((w) => w.id === id)) return; + setCurrentWorkspaceId(id); + }, []); + + const renameTab = useCallback((id: NodeId, name: string) => { + setWorkspaces((ws) => ws.map((w) => (w.id === id ? { ...w, name } : w))); + }, []); + + /** Close a tab. Kills every PTY in its tree first (so the call site + * doesn't need to walk leaves itself). If the closing tab was current, + * switch to the adjacent one. If it was the only tab, replace it with a + * fresh default — tiletopia must always have at least one workspace. */ + const closeTab = useCallback( + (id: NodeId) => { + const target = workspacesRef.current.find((w) => w.id === id); + if (!target) return; + for (const leaf of walkLeaves(target.tree)) { + const paneId = paneIdByLeafRef.current.get(leaf.id); + if (paneId != null) { + void killPane(paneId).catch((e) => + console.warn("killPane failed:", e), + ); + paneIdByLeafRef.current.delete(leaf.id); + } + } + + const idx = workspacesRef.current.findIndex((w) => w.id === id); + const wasCurrent = currentWorkspaceIdRef.current === id; + + setWorkspaces((prev) => { + const next = prev.filter((w) => w.id !== id); + if (next.length === 0) { + return [ + { + id: newId(), + name: "Default", + tree: newLeaf(defaultShellAsLeafProps(defaultShell)), + }, + ]; + } + return next; + }); + + setActiveLeafByWorkspace((prev) => { + if (!prev.has(id)) return prev; + const m = new Map(prev); + m.delete(id); + return m; + }); + + if (wasCurrent) { + // Use the snapshot of workspaces BEFORE removal to find a neighbor. + const remaining = workspacesRef.current.filter((w) => w.id !== id); + if (remaining.length > 0) { + const nextIdx = Math.min(idx, remaining.length - 1); + setCurrentWorkspaceId(remaining[nextIdx].id); + } + // else: setWorkspaces above will populate a fresh default; the + // workspaces effect runs and updates currentWorkspaceIdRef below. + } + }, + [defaultShell], + ); + + // When the workspaces list changes (load, close last tab, etc.), make sure + // currentWorkspaceId points at something real. + useEffect(() => { + if (workspaces.length === 0) return; + if (!workspaces.some((w) => w.id === currentWorkspaceId)) { + setCurrentWorkspaceId(workspaces[0].id); + } + }, [workspaces, currentWorkspaceId]); + const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { setTree((t) => changeLabel(t, leafId, label)); }, []); @@ -583,6 +777,65 @@ export default function App() { return; } + // Ctrl+T — new tab. + if (ctrl && !shift && !alt && key === "t") { + e.preventDefault(); + e.stopPropagation(); + createTab(); + return; + } + + // Ctrl+Shift+T — close current tab. Confirms via window.confirm when + // the tab has live panes (matches the X-button popover's intent + // without re-using its anchored UI from the keyboard path). + if (ctrl && shift && !alt && key === "t") { + e.preventDefault(); + e.stopPropagation(); + const wsId = currentWorkspaceIdRef.current; + const ws = workspacesRef.current.find((w) => w.id === wsId); + if (!ws) return; + const paneCount = leafCount(ws.tree); + if (paneCount > 0) { + const labels = Array.from(walkLeaves(ws.tree)) + .map((l) => l.label ?? l.distro ?? `(${l.shellKind})`) + .join(", "); + const ok = window.confirm( + `Close tab "${ws.name}"? This will kill ${paneCount} pane${paneCount === 1 ? "" : "s"}: ${labels}`, + ); + if (!ok) return; + } + closeTab(wsId); + return; + } + + // Ctrl+PageDown / Ctrl+PageUp — switch to next / previous tab. + if (ctrl && !shift && !alt && (key === "pagedown" || key === "pageup")) { + e.preventDefault(); + e.stopPropagation(); + const ws = workspacesRef.current; + if (ws.length < 2) return; + const idx = ws.findIndex((w) => w.id === currentWorkspaceIdRef.current); + if (idx < 0) return; + const delta = key === "pagedown" ? 1 : -1; + const nextIdx = (idx + delta + ws.length) % ws.length; + switchTab(ws[nextIdx].id); + return; + } + + // Ctrl+1..9 — switch to tab N (1-indexed, capped at 9 even if more). + if (ctrl && !shift && !alt && e.code.startsWith("Digit")) { + const n = parseInt(e.code.slice(5), 10); + if (n >= 1 && n <= 9) { + const ws = workspacesRef.current; + if (ws[n - 1]) { + e.preventDefault(); + e.stopPropagation(); + switchTab(ws[n - 1].id); + return; + } + } + } + // All remaining shortcuts require Ctrl+Shift with no Alt. if (!ctrl || !shift || alt) return; @@ -629,7 +882,7 @@ export default function App() { window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [split, close, toggleBroadcast, promoteActive]); + }, [split, close, toggleBroadcast, promoteActive, createTab, closeTab, switchTab]); // Waiters keyed by leaf id — used by the MCP spawn_pane / connect_host // handlers, which must reply with the new paneId but can only get one @@ -1408,11 +1661,11 @@ export default function App() { [paletteOpen, tree], ); - // ---- flat layout — leaves as siblings keyed by id; gutters separate ----- - // This lets React preserve LeafPane (and its PTY) across any tree reshape - // — split, close, preset application, etc. The tree changes, the boxes - // change, the leaves re-position but DON'T unmount. - const layout = useMemo(() => flattenLayout(tree), [tree]); + // ---- flat layout per workspace — leaves as siblings keyed by id --------- + // Each workspace gets its own .workspace-layer (rendered below). Layouts + // are recomputed inline per tab; the per-workspace render preserves + // LeafPane (and its PTY) across any tree reshape and across tab switches + // (since non-active layers are visibility:hidden rather than unmounted). const paneWrapRef = useRef(null); const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => { setTree((t) => updateSplitRatio(t, splitId, ratio)); @@ -1651,32 +1904,62 @@ export default function App() { + +
{ready && ( - {layout.leaves.map(({ leaf, box }) => ( -
- -
- ))} - {layout.gutters.map((g) => ( - - ))} + {workspaces.map((ws) => { + const wsLayout = flattenLayout(ws.tree); + const isCurrent = ws.id === currentWorkspaceId; + return ( +
+ {wsLayout.leaves.map(({ leaf, box }) => ( +
+ +
+ ))} + {isCurrent && + wsLayout.gutters.map((g) => ( + + ))} +
+ ); + })}
)}
diff --git a/src/components/TabStrip.css b/src/components/TabStrip.css new file mode 100644 index 0000000..4a3c17b --- /dev/null +++ b/src/components/TabStrip.css @@ -0,0 +1,178 @@ +.tab-strip { + flex: 0 0 auto; + display: flex; + align-items: stretch; + gap: 2px; + padding: 4px 8px 0 8px; + background: #161616; + border-bottom: 1px solid #2a2a2a; + font-size: 12px; + color: #aaa; + user-select: none; + overflow-x: auto; + overflow-y: visible; + min-height: 28px; + box-sizing: border-box; + white-space: nowrap; + /* Allow the inline confirm popover to spill over the bottom edge instead + of being clipped by overflow:hidden. overflow-y:visible alongside + overflow-x:auto only works because confirm popovers position themselves + absolutely. */ +} + +.tab-strip-item { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 4px 4px 10px; + border: 1px solid #2a2a2a; + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + background: #1a1a1a; + color: #999; + cursor: pointer; + max-width: 200px; + min-width: 80px; + flex-shrink: 0; +} +.tab-strip-item:hover { + background: #232323; + color: #ccc; +} +.tab-strip-item.active { + background: #0c0c0c; + color: #e6e6e6; + border-color: #2a5a8c; + /* Pull the active tab visually onto the pane area below it. */ + margin-bottom: -1px; +} + +.tab-strip-name { + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; +} + +.tab-strip-rename { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + background: #0c0c0c; + color: #e6e6e6; + border: 1px solid #2a5a8c; + border-radius: 2px; + padding: 1px 4px; + width: 100%; + flex: 1 1 auto; + min-width: 0; + outline: none; +} + +.tab-strip-close { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 13px; + line-height: 1; + background: transparent; + color: #777; + border: none; + border-radius: 2px; + padding: 0 4px; + cursor: pointer; + flex: 0 0 auto; +} +.tab-strip-close:hover { + background: #c94040; + color: #fff; +} + +.tab-strip-add { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 14px; + line-height: 1; + background: #1a1a1a; + color: #aaa; + border: 1px solid #2a2a2a; + border-radius: 4px; + padding: 2px 10px; + cursor: pointer; + align-self: center; + margin-left: 4px; + flex: 0 0 auto; +} +.tab-strip-add:hover { + background: #1a3a5c; + color: #fff; + border-color: #2a5a8c; +} + +/* Inline confirm popover anchored to the close button — spills below the + strip. Plain matte panel; reuses the existing app palette. */ +.tab-strip-confirm { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 50; + min-width: 260px; + max-width: 360px; + background: #1a1a1a; + color: #e6e6e6; + border: 1px solid #c98a1f; + border-radius: 4px; + padding: 10px 12px; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + white-space: normal; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); + cursor: default; +} +.tab-strip-confirm-title { + font-weight: 600; + color: #f0c060; + margin-bottom: 6px; +} +.tab-strip-confirm-body { + color: #ccc; + margin-bottom: 10px; +} +.tab-strip-confirm-labels { + color: #e6e6e6; + font-size: 11px; + margin-top: 4px; + word-break: break-word; +} +.tab-strip-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} +.tab-strip-confirm-btn { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + background: #2a2a2a; + color: #ddd; + border: 1px solid #333; + border-radius: 3px; + padding: 4px 10px; + cursor: pointer; +} +.tab-strip-confirm-btn.cancel:hover { + background: #333; +} +.tab-strip-confirm-btn.destructive { + background: #4a1010; + color: #f8c0c0; + border-color: #c94040; +} +.tab-strip-confirm-btn.destructive:hover { + background: #6a1818; + color: #fff; +} diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx new file mode 100644 index 0000000..cb06e0c --- /dev/null +++ b/src/components/TabStrip.tsx @@ -0,0 +1,212 @@ +import { + useState, + useRef, + useEffect, + useCallback, + useMemo, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree"; +import "./TabStrip.css"; + +interface TabStripProps { + workspaces: Workspace[]; + currentWorkspaceId: NodeId | null; + onSwitch: (id: NodeId) => void; + onCreate: () => void; + /** Caller MUST handle PTY teardown for the tab's leaves before removing it + * from the workspaces list. TabStrip just gates the action on user + * confirm. */ + onClose: (id: NodeId) => void; + onRename: (id: NodeId, name: string) => void; +} + +/** Tab strip displayed above the pane area. One pill per workspace; click to + * switch, double-click name to rename, × to close (with inline confirm if + * the tab has live panes), + at the end to spawn a new blank workspace. */ +export default function TabStrip({ + workspaces, + currentWorkspaceId, + onSwitch, + onCreate, + onClose, + onRename, +}: TabStripProps) { + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(""); + const editInputRef = useRef(null); + const [confirmingId, setConfirmingId] = useState(null); + + const startEdit = useCallback( + (id: NodeId, current: string, e: ReactMouseEvent) => { + e.stopPropagation(); + setEditingId(id); + setDraft(current); + queueMicrotask(() => editInputRef.current?.select()); + }, + [], + ); + const commitEdit = useCallback(() => { + if (editingId == null) return; + const trimmed = draft.trim(); + if (trimmed) onRename(editingId, trimmed); + setEditingId(null); + }, [editingId, draft, onRename]); + const cancelEdit = useCallback(() => setEditingId(null), []); + const onEditKey = useCallback( + (e: ReactKeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + commitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + }, + [commitEdit, cancelEdit], + ); + + // Outside-click dismissal for the inline confirm popover. + useEffect(() => { + if (confirmingId == null) return; + const onDocClick = () => setConfirmingId(null); + // Run on next tick so the click that opened the confirm doesn't immediately close it. + const id = window.setTimeout( + () => window.addEventListener("click", onDocClick), + 0, + ); + return () => { + clearTimeout(id); + window.removeEventListener("click", onDocClick); + }; + }, [confirmingId]); + + const confirmingWorkspace = useMemo( + () => workspaces.find((w) => w.id === confirmingId) ?? null, + [workspaces, confirmingId], + ); + + const confirmingPaneLabels = useMemo(() => { + if (!confirmingWorkspace) return [] as string[]; + return Array.from(walkLeaves(confirmingWorkspace.tree)).map( + (l) => l.label ?? l.distro ?? `(${l.shellKind})`, + ); + }, [confirmingWorkspace]); + + const requestClose = useCallback( + (id: NodeId, e: ReactMouseEvent) => { + e.stopPropagation(); + const w = workspaces.find((ws) => ws.id === id); + if (!w) return; + // Silent close when the tab has no live panes (e.g. empty default leaf + // with no PTY yet — but every leaf has one, so effectively never zero). + // The leafCount check leaves room for a future "empty tab" state. + if (leafCount(w.tree) === 0) { + onClose(id); + return; + } + setConfirmingId(id); + }, + [workspaces, onClose], + ); + + const confirmClose = useCallback( + (e: ReactMouseEvent) => { + e.stopPropagation(); + if (confirmingId == null) return; + const id = confirmingId; + setConfirmingId(null); + onClose(id); + }, + [confirmingId, onClose], + ); + + return ( +
+ {workspaces.map((w) => { + const isActive = w.id === currentWorkspaceId; + const isEditing = editingId === w.id; + const isConfirming = confirmingId === w.id; + return ( +
onSwitch(w.id)} + onDoubleClick={(e) => startEdit(w.id, w.name, e)} + title={`Switch to ${w.name}`} + > + {isEditing ? ( + setDraft(e.target.value)} + onKeyDown={onEditKey} + onBlur={commitEdit} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {w.name} + )} + + {isConfirming && ( +
e.stopPropagation()} + > +
+ Close "{confirmingWorkspace?.name}"? +
+
+ This will kill {confirmingPaneLabels.length} pane + {confirmingPaneLabels.length === 1 ? "" : "s"}: +
+ {confirmingPaneLabels.join(", ")} +
+
+
+ + +
+
+ )} +
+ ); + })} + +
+ ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 36f5839..3eee830 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -273,8 +273,18 @@ export default function XtermPane({ }); ro.observe(container); - // Focus so typing immediately lands in the terminal. - term?.focus(); + // Focus so typing immediately lands in the terminal — but ONLY if the + // host container is actually visible. With multiple tabs (workspaces), + // a pane in a hidden tab still mounts and spawns; we must not yank + // focus into a tab the user can't see. CSS `visibility: hidden` is + // inherited, so the computed style on the container reflects whether + // any ancestor (workspace-layer) is hiding us. + if ( + container.isConnected && + getComputedStyle(container).visibility !== "hidden" + ) { + term?.focus(); + } })(); return () => { diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 58a755a..41f9d08 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -21,6 +21,10 @@ import { MAX_FONT_SIZE, serialize, deserialize, + serializeWorkspaces, + deserializeWorkspaces, + singletonEnvelope, + WORKSPACES_VERSION, presetSingle, presetTwoColumns, presetThreeColumns, @@ -665,3 +669,82 @@ describe("serialize / deserialize", () => { expect(back.sshHostId).toBe("h-1"); }); }); + +describe("workspaces envelope", () => { + it("roundtrips a multi-workspace envelope", () => { + const env = { + version: WORKSPACES_VERSION, + workspaces: [ + { id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) }, + { + id: "w2", + name: "beta", + tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()), + }, + ], + }; + const back = deserializeWorkspaces(serializeWorkspaces(env)); + expect(back).toEqual(env); + }); + + it("returns null on invalid JSON", () => { + expect(deserializeWorkspaces("not json")).toBeNull(); + }); + + it("returns null when version is wrong or workspaces is missing", () => { + expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull(); + expect(deserializeWorkspaces('{"version": 2}')).toBeNull(); + }); + + it("returns null when an envelope has zero valid workspaces", () => { + expect( + deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'), + ).toBeNull(); + }); + + it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => { + const legacy = JSON.stringify({ + kind: "split", + id: "s1", + orientation: "h", + ratio: 0.5, + a: { kind: "leaf", id: "a", distro: "Ubuntu" }, + b: { kind: "leaf", id: "b", distro: "PowerShell" }, + }); + const env = deserializeWorkspaces(legacy); + expect(env).not.toBeNull(); + expect(env!.version).toBe(WORKSPACES_VERSION); + expect(env!.workspaces.length).toBe(1); + expect(env!.workspaces[0].name).toBe("Default"); + // Per-leaf legacy migration also applied — PowerShell sentinel mapped. + const tree = env!.workspaces[0].tree as SplitNode; + expect((tree.a as LeafNode).shellKind).toBe("wsl"); + expect((tree.b as LeafNode).shellKind).toBe("powershell"); + expect((tree.b as LeafNode).distro).toBeUndefined(); + }); + + it("singletonEnvelope wraps a tree with a fresh workspace id", () => { + const t = newLeaf({ label: "only" }); + const env = singletonEnvelope(t, "Main"); + expect(env.workspaces.length).toBe(1); + expect(env.workspaces[0].name).toBe("Main"); + expect(env.workspaces[0].tree).toBe(t); + expect(typeof env.workspaces[0].id).toBe("string"); + expect(env.workspaces[0].id).not.toBe(t.id); + }); + + it("skips malformed workspaces but keeps the valid ones", () => { + const env = { + version: WORKSPACES_VERSION, + workspaces: [ + { id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } }, + { id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } }, + { id: "no-tree", name: "still-bad" }, + ], + }; + const back = deserializeWorkspaces(JSON.stringify(env)); + expect(back).not.toBeNull(); + expect(back!.workspaces.length).toBe(1); + expect(back!.workspaces[0].id).toBe("ok"); + }); +}); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index ce383cd..352dbec 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -703,6 +703,92 @@ export function deserialize(json: string): TreeNode | null { } } +// ---- workspaces envelope --------------------------------------------------- + +/** One named tab in the tab strip. Each workspace owns its own tile tree; + * leaf NodeIds remain globally unique across workspaces so the app-level + * paneIdByLeaf map continues to work without partitioning. */ +export interface Workspace { + id: NodeId; + name: string; + tree: TreeNode; +} + +/** Top-level persistence shape. `version` bumps when the envelope schema + * changes; the v1 shape was a bare TreeNode at the JSON root, migrated + * automatically by {@link deserializeWorkspaces}. */ +export interface WorkspacesEnvelope { + version: 2; + workspaces: Workspace[]; +} + +export const WORKSPACES_VERSION = 2 as const; + +/** Construct an envelope wrapping a single workspace with the given tree. + * Used for first-launch and as the destination of the v1→v2 migration. */ +export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope { + return { + version: WORKSPACES_VERSION, + workspaces: [{ id: newId(), name, tree }], + }; +} + +export function serializeWorkspaces(env: WorkspacesEnvelope): string { + return JSON.stringify(env); +} + +/** Parse a persisted workspaces envelope. Accepts: + * - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }` + * - Legacy v1 shape: a bare {@link TreeNode} — wrapped as one workspace + * named "Default" with a fresh id. + * Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to + * each workspace's tree. Returns null when the JSON is unrecognisable. */ +export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + + // v1: bare TreeNode at the root + if (isTreeNode(parsed)) { + return singletonEnvelope(migrateLegacyLeaves(parsed)); + } + + // v2: envelope + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { version?: unknown }).version === WORKSPACES_VERSION && + Array.isArray((parsed as { workspaces?: unknown }).workspaces) + ) { + const raw = (parsed as { workspaces: unknown[] }).workspaces; + const workspaces: Workspace[] = []; + for (const w of raw) { + if ( + typeof w !== "object" || + w === null || + typeof (w as { id?: unknown }).id !== "string" || + typeof (w as { name?: unknown }).name !== "string" || + !isTreeNode((w as { tree?: unknown }).tree) + ) { + continue; + } + const tw = w as { id: string; name: string; tree: TreeNode }; + workspaces.push({ + id: tw.id, + name: tw.name, + tree: migrateLegacyLeaves(tw.tree), + }); + } + if (workspaces.length === 0) return null; + return { version: WORKSPACES_VERSION, workspaces }; + } + + return null; +} + /** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */ const LEGACY_POWERSHELL_DISTRO = "PowerShell"; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index b3692d1..3e0941b 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -30,6 +30,21 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ }, ], }, + { + title: "Tabs", + items: [ + { keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" }, + { + keys: "Ctrl+Shift+T", + description: "Close current tab (confirms when the tab has live panes)", + }, + { + keys: "Ctrl+PageDown / Ctrl+PageUp", + description: "Switch to next / previous tab", + }, + { keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" }, + ], + }, { title: "Navigation", items: [ @@ -108,6 +123,10 @@ export const TIPS: TipSpec[] = [ title: "Workspace persistence", body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.", }, + { + title: "Tabs (workspaces)", + body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.", + }, { title: "MCP server (let Claude drive the workspace)", body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",