Phase 1: tabbed workspaces
Each tab is an independent tile tree; PTYs in non-active tabs keep
running (render-all-panes with visibility:hidden on inactive layers
so xterm.js's fit() still sees valid dimensions and the existing
per-pane resize dedupe absorbs no-op SIGWINCHes).
workspace.json shape goes from a bare TreeNode to
`{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1
auto-wrap migration (the old single tree becomes one tab named
"Default").
App.tsx wraps the old single-tree state in workspace-aware state
but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as
identity-stable derived wrappers (reading currentWorkspaceId from a
ref), so the bulk of App.tsx stays unchanged.
XtermPane's initial term.focus() now checks `visibility !== "hidden"`
on the container so a pane mounting inside a hidden tab on app boot
doesn't yank focus away from the active tab. The focus poller is
scoped to the active workspace layer for the same reason.
Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm
when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9
switch to tab N. README + help overlay auto-generated from
shortcuts.ts.
79/79 vitest pass (7 new envelope-migration cases). tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c92847413b
commit
1a035ad0a6
8 changed files with 933 additions and 52 deletions
383
src/App.tsx
383
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<TreeNode>(() => newLeaf());
|
||||
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(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<Workspace[]>(() => {
|
||||
const t = newLeaf();
|
||||
return [{ id: newId(), name: "Default", tree: t }];
|
||||
});
|
||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<NodeId>(
|
||||
() => "", // 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<NodeId, NodeId | null>
|
||||
>(() => 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<React.Dispatch<React.SetStateAction<TreeNode>>>(
|
||||
(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<React.SetStateAction<NodeId | null>>
|
||||
>(
|
||||
(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<string[]>([]);
|
||||
const [defaultShell, setDefaultShell] = useState<DefaultShell>({
|
||||
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<typeof deserializeWorkspaces> = 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<HTMLDivElement>(null);
|
||||
const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => {
|
||||
setTree((t) => updateSplitRatio(t, splitId, ratio));
|
||||
|
|
@ -1651,32 +1904,62 @@ export default function App() {
|
|||
</span>
|
||||
</header>
|
||||
|
||||
<TabStrip
|
||||
workspaces={workspaces}
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
onSwitch={switchTab}
|
||||
onCreate={createTab}
|
||||
onClose={closeTab}
|
||||
onRename={renameTab}
|
||||
/>
|
||||
|
||||
<div className="pane-wrap" ref={paneWrapRef}>
|
||||
{ready && (
|
||||
<OrchestrationProvider value={orch}>
|
||||
{layout.leaves.map(({ leaf, box }) => (
|
||||
<div
|
||||
key={leaf.id}
|
||||
className="leaf-slot"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${box.top * 100}%`,
|
||||
left: `${box.left * 100}%`,
|
||||
width: `${box.width * 100}%`,
|
||||
height: `${box.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<LeafPane leaf={leaf} />
|
||||
</div>
|
||||
))}
|
||||
{layout.gutters.map((g) => (
|
||||
<Gutter
|
||||
key={g.splitId}
|
||||
info={g}
|
||||
containerRef={paneWrapRef}
|
||||
onRatioChange={onGutterRatio}
|
||||
/>
|
||||
))}
|
||||
{workspaces.map((ws) => {
|
||||
const wsLayout = flattenLayout(ws.tree);
|
||||
const isCurrent = ws.id === currentWorkspaceId;
|
||||
return (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-layer${isCurrent ? " active" : ""}`}
|
||||
data-workspace-id={ws.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
visibility: isCurrent ? "visible" : "hidden",
|
||||
pointerEvents: isCurrent ? "auto" : "none",
|
||||
zIndex: isCurrent ? 1 : 0,
|
||||
}}
|
||||
aria-hidden={isCurrent ? "false" : "true"}
|
||||
>
|
||||
{wsLayout.leaves.map(({ leaf, box }) => (
|
||||
<div
|
||||
key={leaf.id}
|
||||
className="leaf-slot"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${box.top * 100}%`,
|
||||
left: `${box.left * 100}%`,
|
||||
width: `${box.width * 100}%`,
|
||||
height: `${box.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<LeafPane leaf={leaf} />
|
||||
</div>
|
||||
))}
|
||||
{isCurrent &&
|
||||
wsLayout.gutters.map((g) => (
|
||||
<Gutter
|
||||
key={g.splitId}
|
||||
info={g}
|
||||
containerRef={paneWrapRef}
|
||||
onRatioChange={onGutterRatio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</OrchestrationProvider>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue