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
10
README.md
10
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.
|
||||
|
||||
<!-- SHORTCUTS:END -->
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
178
src/components/TabStrip.css
Normal file
178
src/components/TabStrip.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
212
src/components/TabStrip.tsx
Normal file
212
src/components/TabStrip.tsx
Normal file
|
|
@ -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<NodeId | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<NodeId | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="tab-strip" role="tablist">
|
||||
{workspaces.map((w) => {
|
||||
const isActive = w.id === currentWorkspaceId;
|
||||
const isEditing = editingId === w.id;
|
||||
const isConfirming = confirmingId === w.id;
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`tab-strip-item${isActive ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={isActive ? "true" : "false"}
|
||||
onClick={() => onSwitch(w.id)}
|
||||
onDoubleClick={(e) => startEdit(w.id, w.name, e)}
|
||||
title={`Switch to ${w.name}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="tab-strip-rename"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onEditKey}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="tab-strip-name">{w.name}</span>
|
||||
)}
|
||||
<button
|
||||
className="tab-strip-close"
|
||||
onClick={(e) => requestClose(w.id, e)}
|
||||
title="Close tab"
|
||||
aria-label={`Close tab ${w.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{isConfirming && (
|
||||
<div
|
||||
className="tab-strip-confirm"
|
||||
role="dialog"
|
||||
aria-label="Confirm close tab"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-strip-confirm-title">
|
||||
Close "{confirmingWorkspace?.name}"?
|
||||
</div>
|
||||
<div className="tab-strip-confirm-body">
|
||||
This will kill {confirmingPaneLabels.length} pane
|
||||
{confirmingPaneLabels.length === 1 ? "" : "s"}:
|
||||
<div className="tab-strip-confirm-labels">
|
||||
{confirmingPaneLabels.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tab-strip-confirm-actions">
|
||||
<button
|
||||
className="tab-strip-confirm-btn cancel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="tab-strip-confirm-btn destructive"
|
||||
onClick={confirmClose}
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="tab-strip-add"
|
||||
onClick={onCreate}
|
||||
title="New tab (Ctrl+T)"
|
||||
aria-label="New tab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue