Reads ~/.claude/projects/*.jsonl transcripts from the open WSL panes'
distros and shows per-session token counts + estimated USD cost, with a
running total in the titlebar.
Backend (src-tauri/src/usage.rs): new get_claude_usage command. For each
distro it probes $HOME once via wsl.exe, reaches the transcripts over the
\\wsl.localhost UNC share, and tallies message.usage per model per
session (summed by each line's model, since a session can switch models).
Results cached by (path,size,mtime) so polling only re-parses the file
that grew; recency-capped (30d / 50 sessions) to bound scan cost.
Windows-only; returns [] elsewhere. quiet_command made pub(crate).
Frontend: src/lib/usage.ts holds the pricing table (per-MTok rates,
matched by model-family substring) + cost/format helpers, so rates are
editable without recompiling Rust. UsagePanel.tsx mirrors the MCP panel
modal; rows whose transcript cwd matches an open pane are highlighted
with a [pane: label] tag. App polls every 20s (visible windows) for the
titlebar 💰 total and every 5s while the panel is open. Ctrl+Shift+U
opens it; added to shortcuts.ts + regenerated README.
tsc clean. Rust builds on the Windows host; needs runtime verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2335 lines
83 KiB
TypeScript
2335 lines
83 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
listDistros,
|
||
loadWorkspace,
|
||
saveWorkspace,
|
||
listSshHosts,
|
||
saveSshHosts,
|
||
setHostPassword,
|
||
deleteHostPassword,
|
||
mcpStart,
|
||
mcpStop,
|
||
mcpStatus as mcpStatusCmd,
|
||
mcpRegenerateToken,
|
||
mcpUpdateState,
|
||
onMcpRequest,
|
||
mcpActionReply,
|
||
mcpPolicyLoad,
|
||
mcpPolicySave,
|
||
writeToPane,
|
||
killPane,
|
||
markPaneTransferring,
|
||
claimPane,
|
||
createPaneWindow,
|
||
takePendingWindowInit,
|
||
pushWindowWorkspaces,
|
||
getClaudeUsage,
|
||
type PaneId,
|
||
type SpawnSpec,
|
||
type SessionUsage,
|
||
type SshHost,
|
||
type McpStatus,
|
||
type McpMirror,
|
||
type McpMirroredLeaf,
|
||
type McpMirroredHost,
|
||
type McpActionRequest,
|
||
type McpAuditEntry,
|
||
} from "./ipc";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||
|
||
const MAIN_WINDOW_LABEL = "main";
|
||
/** Current window label, captured once at module load — used to decide
|
||
* load path (load_workspace vs take_pending_window_init) and to push
|
||
* this window's state to the cross-window aggregator. */
|
||
const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label;
|
||
const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL;
|
||
|
||
/** `take_pending_window_init` is a DESTRUCTIVE backend read (it removes the
|
||
* entry). React StrictMode runs the mount effect twice in dev, so a plain
|
||
* call would consume the payload on the first (cancelled) pass and hand the
|
||
* second pass `null` — booting a fresh "Default" workspace and spawning a new
|
||
* PTY instead of adopting the transferred one (session lost). Memoize the
|
||
* promise at module scope so the backend take happens exactly once per window
|
||
* and every effect pass awaits the same result. */
|
||
let pendingInitOnce: Promise<Awaited<ReturnType<typeof takePendingWindowInit>>> | null =
|
||
null;
|
||
const consumePendingWindowInit = () => {
|
||
if (!pendingInitOnce) {
|
||
pendingInitOnce = takePendingWindowInit(CURRENT_WINDOW_LABEL);
|
||
}
|
||
return pendingInitOnce;
|
||
};
|
||
import {
|
||
type TreeNode,
|
||
type NodeId,
|
||
type Orientation,
|
||
type LeafNode,
|
||
type LeafShellSpec,
|
||
type Workspace,
|
||
newLeaf,
|
||
newId,
|
||
splitLeaf,
|
||
splitLeafWith,
|
||
closeLeaf,
|
||
findLeaf,
|
||
leafCount,
|
||
walkLeaves,
|
||
setLeafShell,
|
||
changeLabel,
|
||
toggleBroadcast as toggleBroadcastInTree,
|
||
toggleMcpAllow as toggleMcpAllowInTree,
|
||
setAllBroadcast,
|
||
adjustFontSize,
|
||
adjustAllFontSizes,
|
||
reshapeToPreset,
|
||
flattenLayout,
|
||
updateSplitRatio,
|
||
swapLeaves,
|
||
findNeighborInDirection,
|
||
promoteLeaf,
|
||
MIN_PANE_PX,
|
||
type Direction,
|
||
serialize,
|
||
serializeWorkspaces,
|
||
deserializeWorkspaces,
|
||
singletonEnvelope,
|
||
presetSingle,
|
||
presetTwoColumns,
|
||
presetThreeColumns,
|
||
presetTwoRows,
|
||
presetTwoByTwo,
|
||
} from "./lib/layout/tree";
|
||
import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration";
|
||
import LeafPane from "./lib/layout/LeafPane";
|
||
import Gutter from "./lib/layout/Gutter";
|
||
import Notifications, { type Toast } from "./components/Notifications";
|
||
import Palette from "./components/Palette";
|
||
import HostManager from "./components/HostManager";
|
||
import Help from "./components/Help";
|
||
import McpPanel from "./components/McpPanel";
|
||
import UsagePanel from "./components/UsagePanel";
|
||
import { totalCost, formatUsd } from "./lib/usage";
|
||
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
|
||
import TabStrip from "./components/TabStrip";
|
||
import "./App.css";
|
||
import "./lib/layout/Gutter.css";
|
||
|
||
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
||
|
||
/** Picker default for *new* panes. SSH never lives here — SSH connections
|
||
* are always explicit, never a default. */
|
||
type DefaultShell =
|
||
| { shellKind: "wsl"; distro?: string }
|
||
| { shellKind: "powershell" };
|
||
|
||
function isInteractiveDistro(name: string): boolean {
|
||
return !name.toLowerCase().startsWith("docker-desktop");
|
||
}
|
||
|
||
/** Map a {@link DefaultShell} onto the props newLeaf expects. */
|
||
function defaultShellAsLeafProps(d: DefaultShell): Partial<LeafNode> {
|
||
if (d.shellKind === "powershell") return { shellKind: "powershell" };
|
||
return { shellKind: "wsl", distro: d.distro };
|
||
}
|
||
|
||
/** Cap a string for display in modals / audit summaries. Single-line, max
|
||
* 60 visible chars, control characters escaped so secrets pasted into a
|
||
* write_pane call don't print as gibberish in the modal. */
|
||
function truncateForSummary(s: string, cap = 60): string {
|
||
const oneLine = s.replace(/\r?\n/g, "\\n").replace(/\t/g, "\\t");
|
||
return oneLine.length > cap ? oneLine.slice(0, cap) + "…" : oneLine;
|
||
}
|
||
|
||
/** Short human-readable form of a SpawnSpec, used in MCP confirm summaries. */
|
||
function describeSpec(spec: SpawnSpec): string {
|
||
if (spec.kind === "wsl") return `WSL${spec.distro ? ` (${spec.distro})` : ""}`;
|
||
if (spec.kind === "powershell") return "PowerShell";
|
||
return `SSH${spec.host ? ` to ${spec.host}` : ""}`;
|
||
}
|
||
|
||
export default function App() {
|
||
// ---- top-level state -----------------------------------------------------
|
||
// 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",
|
||
});
|
||
const [hosts, setHosts] = useState<SshHost[]>([]);
|
||
const [hostManagerOpen, setHostManagerOpen] = useState(false);
|
||
const [helpOpen, setHelpOpen] = useState(false);
|
||
const [mcpStatus, setMcpStatus] = useState<McpStatus>({
|
||
running: false,
|
||
url: null,
|
||
token: null,
|
||
});
|
||
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
||
const [usagePanelOpen, setUsagePanelOpen] = useState(false);
|
||
const [usageSessions, setUsageSessions] = useState<SessionUsage[]>([]);
|
||
const [usageLoading, setUsageLoading] = useState(false);
|
||
const [ready, setReady] = useState(false);
|
||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||
const [defaultShellMenuOpen, setDefaultShellMenuOpen] = useState(false);
|
||
const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
|
||
|
||
// ---- non-reactive lookups -----------------------------------------------
|
||
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
|
||
const nextNotifIdRef = useRef(1);
|
||
/** Leaves that just arrived via a window transfer, mapped to the
|
||
* existing PaneId their XtermPane should adopt. One-shot: cleared in
|
||
* registerPaneId once the pane registers. */
|
||
const transferredPaneIdsRef = useRef<Map<NodeId, PaneId>>(new Map());
|
||
const treeRef = useRef(tree);
|
||
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 () => {
|
||
// First: is this a detached window with a pending transfer payload?
|
||
// Non-main windows ALWAYS go through this path (they never read
|
||
// workspace.json — only main owns it). A detached window with no
|
||
// pending init is the dev-reload / edge case; we boot with a blank
|
||
// default workspace.
|
||
let initialEnvelope: ReturnType<typeof deserializeWorkspaces> = null;
|
||
let adoptedLeafId: NodeId | null = null;
|
||
|
||
if (!IS_MAIN_WINDOW) {
|
||
try {
|
||
const pending = await consumePendingWindowInit();
|
||
if (pending) {
|
||
try {
|
||
const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode;
|
||
if (adoptedLeaf && adoptedLeaf.kind === "leaf") {
|
||
transferredPaneIdsRef.current.set(adoptedLeaf.id, pending.paneId);
|
||
adoptedLeafId = adoptedLeaf.id;
|
||
initialEnvelope = {
|
||
version: 2,
|
||
workspaces: [
|
||
{
|
||
id: newId(),
|
||
name: pending.workspaceName || "Detached",
|
||
tree: adoptedLeaf,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.warn("invalid pending leafJson:", e);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn("takePendingWindowInit failed:", e);
|
||
}
|
||
} else {
|
||
// Main window: load workspace.json (and legacy fallback).
|
||
try {
|
||
const json = await loadWorkspace();
|
||
if (json) initialEnvelope = deserializeWorkspaces(json);
|
||
} catch (e) {
|
||
console.warn("loadWorkspace failed:", e);
|
||
}
|
||
if (!initialEnvelope) {
|
||
try {
|
||
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||
if (legacy) {
|
||
initialEnvelope = deserializeWorkspaces(legacy);
|
||
if (initialEnvelope) {
|
||
void saveWorkspace(serializeWorkspaces(initialEnvelope));
|
||
}
|
||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||
}
|
||
} catch (e) {
|
||
console.warn("legacy localStorage migration failed:", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
let resolvedDistros: string[] = [];
|
||
try {
|
||
resolvedDistros = await listDistros();
|
||
} catch (e) {
|
||
console.warn("list_distros failed:", e);
|
||
}
|
||
|
||
let resolvedHosts: SshHost[] = [];
|
||
try {
|
||
resolvedHosts = await listSshHosts();
|
||
} catch (e) {
|
||
console.warn("listSshHosts failed:", e);
|
||
}
|
||
|
||
const initialDefault: DefaultShell = (() => {
|
||
const wslDefault = resolvedDistros.find(isInteractiveDistro);
|
||
if (wslDefault) return { shellKind: "wsl", distro: wslDefault };
|
||
if (resolvedDistros.length > 0) return { shellKind: "wsl", distro: resolvedDistros[0] };
|
||
// No WSL distros — fall back to PowerShell as default.
|
||
return { shellKind: "powershell" };
|
||
})();
|
||
|
||
if (cancelled) return;
|
||
|
||
let envelope = initialEnvelope;
|
||
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);
|
||
if (adoptedLeafId) {
|
||
setActiveLeafByWorkspace((prev) => {
|
||
const m = new Map(prev);
|
||
m.set(envelope!.workspaces[0].id, adoptedLeafId);
|
||
return m;
|
||
});
|
||
}
|
||
setDistros(resolvedDistros);
|
||
setHosts(resolvedHosts);
|
||
setDefaultShell(initialDefault);
|
||
setReady(true);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
// ---- workspace sync to backend aggregator -------------------------------
|
||
// Every window pushes its own workspaces snapshot; the backend merges
|
||
// across windows and debounces the actual workspace.json write (500ms
|
||
// tokio sleep inside Rust). This replaces the v0.3.0 per-window
|
||
// saveWorkspace path which would race when two windows wrote at once.
|
||
useEffect(() => {
|
||
if (!ready) return;
|
||
pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch(
|
||
(e) => console.warn("pushWindowWorkspaces failed:", e),
|
||
);
|
||
}, [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]");
|
||
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
|
||
// subdivide further. Build-mode tsc balks on use-before-declaration.
|
||
const notify = useCallback((message: string) => {
|
||
const id = nextNotifIdRef.current++;
|
||
setNotifications((ns) => [...ns, { id, message }]);
|
||
window.setTimeout(() => {
|
||
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||
}, 5000);
|
||
}, []);
|
||
|
||
// ---- orchestration callbacks --------------------------------------------
|
||
const split = useCallback(
|
||
(leafId: NodeId, orientation: Orientation) => {
|
||
// Refuse the split if it would produce a child pane below MIN_PANE_PX
|
||
// — otherwise spamming the split buttons shrinks panes into nothing.
|
||
const container = paneWrapRef.current;
|
||
if (container) {
|
||
const slot = flattenLayout(treeRef.current).leaves.find(
|
||
(s) => s.leaf.id === leafId,
|
||
);
|
||
if (slot) {
|
||
const rect = container.getBoundingClientRect();
|
||
const paneW = slot.box.width * rect.width;
|
||
const paneH = slot.box.height * rect.height;
|
||
const childW = orientation === "h" ? paneW / 2 : paneW;
|
||
const childH = orientation === "v" ? paneH / 2 : paneH;
|
||
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
|
||
notify(
|
||
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
setTree((t) => {
|
||
const parent = findLeaf(t, leafId);
|
||
const inherit = inheritShellFromParent(parent, defaultShell);
|
||
return splitLeaf(t, leafId, orientation, inherit);
|
||
});
|
||
},
|
||
[defaultShell, notify],
|
||
);
|
||
|
||
// Titlebar "+" — spawn a fresh pane of the picked shell, regardless of
|
||
// what the active pane is running. Splits off the active pane in
|
||
// whichever orientation leaves the new sibling closer to square.
|
||
const addPane = useCallback(() => {
|
||
const container = paneWrapRef.current;
|
||
const layout = flattenLayout(treeRef.current);
|
||
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
|
||
if (!targetId || !container) return;
|
||
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
|
||
if (!slot) return;
|
||
|
||
const rect = container.getBoundingClientRect();
|
||
const paneW = slot.box.width * rect.width;
|
||
const paneH = slot.box.height * rect.height;
|
||
// Split along the longer side so both halves stay close to square.
|
||
const orientation: Orientation = paneW >= paneH ? "h" : "v";
|
||
const childW = orientation === "h" ? paneW / 2 : paneW;
|
||
const childH = orientation === "v" ? paneH / 2 : paneH;
|
||
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
|
||
notify(
|
||
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
setTree((t) =>
|
||
splitLeaf(t, targetId, orientation, defaultShellAsLeafProps(defaultShell)),
|
||
);
|
||
}, [activeLeafId, defaultShell, notify]);
|
||
|
||
// From the SSH host manager's "Connect" button — splits off the active
|
||
// pane and opens an SSH session to the picked host. Same smart-orient as
|
||
// the titlebar "+" path.
|
||
const connectToHost = useCallback(
|
||
(hostId: string) => {
|
||
const container = paneWrapRef.current;
|
||
const layout = flattenLayout(treeRef.current);
|
||
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
|
||
if (!targetId || !container) {
|
||
notify("No pane to split off — open a pane first");
|
||
return;
|
||
}
|
||
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
|
||
if (!slot) return;
|
||
const rect = container.getBoundingClientRect();
|
||
const paneW = slot.box.width * rect.width;
|
||
const paneH = slot.box.height * rect.height;
|
||
const orientation: Orientation = paneW >= paneH ? "h" : "v";
|
||
const childW = orientation === "h" ? paneW / 2 : paneW;
|
||
const childH = orientation === "v" ? paneH / 2 : paneH;
|
||
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
|
||
notify(
|
||
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
|
||
);
|
||
return;
|
||
}
|
||
setTree((t) =>
|
||
splitLeaf(t, targetId, orientation, {
|
||
shellKind: "ssh",
|
||
sshHostId: hostId,
|
||
}),
|
||
);
|
||
},
|
||
[activeLeafId, notify],
|
||
);
|
||
|
||
const close = useCallback(
|
||
(leafId: NodeId) => {
|
||
const paneId = paneIdByLeafRef.current.get(leafId);
|
||
if (paneId != null) {
|
||
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
|
||
paneIdByLeafRef.current.delete(leafId);
|
||
}
|
||
setTree(
|
||
(t) =>
|
||
closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)),
|
||
);
|
||
setActiveLeafId((cur) => (cur === leafId ? null : cur));
|
||
},
|
||
[defaultShell],
|
||
);
|
||
|
||
const setShell = useCallback((leafId: NodeId, spec: LeafShellSpec) => {
|
||
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));
|
||
}, []);
|
||
|
||
const toggleBroadcast = useCallback((leafId: NodeId) => {
|
||
setTree((t) => toggleBroadcastInTree(t, leafId));
|
||
}, []);
|
||
|
||
const toggleMcpAllow = useCallback((leafId: NodeId) => {
|
||
setTree((t) => toggleMcpAllowInTree(t, leafId));
|
||
}, []);
|
||
|
||
// ---- MCP server lifecycle ------------------------------------------------
|
||
const refreshMcpStatus = useCallback(async () => {
|
||
try {
|
||
const st = await mcpStatusCmd();
|
||
setMcpStatus(st);
|
||
} catch (e) {
|
||
console.warn("mcpStatus failed:", e);
|
||
}
|
||
}, []);
|
||
|
||
const startMcp = useCallback(async () => {
|
||
try {
|
||
const st = await mcpStart();
|
||
setMcpStatus(st);
|
||
notify("MCP server started — see panel for URL + token");
|
||
} catch (e) {
|
||
notify(`MCP start failed: ${e}`);
|
||
}
|
||
}, [notify]);
|
||
|
||
const stopMcp = useCallback(async () => {
|
||
try {
|
||
const st = await mcpStop();
|
||
setMcpStatus(st);
|
||
notify("MCP server stopped");
|
||
} catch (e) {
|
||
notify(`MCP stop failed: ${e}`);
|
||
}
|
||
}, [notify]);
|
||
|
||
const regenerateMcpToken = useCallback(async () => {
|
||
try {
|
||
const st = await mcpRegenerateToken();
|
||
setMcpStatus(st);
|
||
notify(
|
||
st.running
|
||
? "MCP token regenerated — update your client config"
|
||
: "MCP token regenerated",
|
||
);
|
||
} catch (e) {
|
||
notify(`MCP token regen failed: ${e}`);
|
||
}
|
||
}, [notify]);
|
||
|
||
// On mount, sync our local mcpStatus with whatever's already running
|
||
// (the backend persists state across HMR reloads).
|
||
useEffect(() => {
|
||
void refreshMcpStatus();
|
||
}, [refreshMcpStatus]);
|
||
|
||
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
|
||
// replacement for the (removed) drag-past-sibling gesture. No-op with a
|
||
// toast if the leaf is at the root or its parent shares orientation
|
||
// with the grandparent — no perpendicular promotion available.
|
||
const promoteActive = useCallback(
|
||
(leafId: NodeId) => {
|
||
const next = promoteLeaf(treeRef.current, leafId);
|
||
if (next === null) {
|
||
notify("Pane can't be promoted (no perpendicular split above it)");
|
||
return;
|
||
}
|
||
setTree(next);
|
||
},
|
||
[notify],
|
||
);
|
||
|
||
const setActive = useCallback((leafId: NodeId) => {
|
||
setActiveLeafId(leafId);
|
||
}, []);
|
||
|
||
// navigateTo — called from XtermPane's attachCustomKeyEventHandler via
|
||
// LeafPane's onNavigate prop. Resolves the target leaf from the current
|
||
// layout tree and sets it active; the LeafPane isActive→focusTrigger
|
||
// effect then refocuses the xterm textarea automatically.
|
||
const navigateTo = useCallback((intent: NavigateIntent) => {
|
||
const currentTree = treeRef.current;
|
||
const currentActiveId = activeLeafId;
|
||
|
||
if (intent.kind === "direction") {
|
||
const layout = flattenLayout(currentTree);
|
||
if (!currentActiveId) {
|
||
const first = layout.leaves[0]?.leaf.id;
|
||
if (first) setActiveLeafId(first);
|
||
return;
|
||
}
|
||
const nextId = findNeighborInDirection(
|
||
layout.leaves,
|
||
currentActiveId,
|
||
intent.dir,
|
||
);
|
||
if (nextId) setActiveLeafId(nextId);
|
||
} else {
|
||
// intent.kind === "index"
|
||
const leaves = Array.from(walkLeaves(currentTree));
|
||
// Clamp: Alt+9 with 3 panes picks the 3rd pane.
|
||
const target = leaves[Math.min(intent.n, leaves.length) - 1];
|
||
if (target) setActiveLeafId(target.id);
|
||
}
|
||
}, [activeLeafId]); // treeRef is a ref — stable, intentionally not listed
|
||
|
||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
||
|
||
// ---- claude usage tracking ----------------------------------------------
|
||
// Reads ~/.claude transcripts in the open WSL panes' distros (backend). The
|
||
// fetch guard collapses overlapping calls (the open panel polls every 5s and
|
||
// the background heartbeat every 20s both call this).
|
||
const usageFetchingRef = useRef(false);
|
||
const refreshUsage = useCallback(async () => {
|
||
if (usageFetchingRef.current) return;
|
||
const distros = new Set<string>();
|
||
for (const leaf of walkLeaves(treeRef.current)) {
|
||
if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro);
|
||
}
|
||
if (distros.size === 0) {
|
||
setUsageSessions([]);
|
||
return;
|
||
}
|
||
usageFetchingRef.current = true;
|
||
setUsageLoading(true);
|
||
try {
|
||
setUsageSessions(await getClaudeUsage(Array.from(distros)));
|
||
} catch (e) {
|
||
console.warn("getClaudeUsage failed:", e);
|
||
} finally {
|
||
usageFetchingRef.current = false;
|
||
setUsageLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Background heartbeat so the titlebar total stays roughly current without
|
||
// the panel open. Gated on visibility so a hidden/minimized window stays quiet.
|
||
useEffect(() => {
|
||
const tick = () => {
|
||
if (document.visibilityState === "visible") void refreshUsage();
|
||
};
|
||
tick();
|
||
const id = window.setInterval(tick, 20000);
|
||
return () => clearInterval(id);
|
||
}, [refreshUsage]);
|
||
|
||
// cwd + label of open WSL panes, for highlighting matching sessions.
|
||
const openPanes = useMemo(
|
||
() =>
|
||
Array.from(walkLeaves(tree))
|
||
.filter((l) => l.shellKind === "wsl")
|
||
.map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })),
|
||
[tree],
|
||
);
|
||
|
||
// Outside-click dismissal for the titlebar dropdowns. Mirrors the
|
||
// per-pane shell-picker pattern in LeafPane.tsx.
|
||
useEffect(() => {
|
||
if (!defaultShellMenuOpen) return;
|
||
const onDocClick = () => setDefaultShellMenuOpen(false);
|
||
window.addEventListener("click", onDocClick);
|
||
return () => window.removeEventListener("click", onDocClick);
|
||
}, [defaultShellMenuOpen]);
|
||
useEffect(() => {
|
||
if (!layoutMenuOpen) return;
|
||
const onDocClick = () => setLayoutMenuOpen(false);
|
||
window.addEventListener("click", onDocClick);
|
||
return () => window.removeEventListener("click", onDocClick);
|
||
}, [layoutMenuOpen]);
|
||
const saveHosts = useCallback(
|
||
(next: SshHost[]) => {
|
||
// Preserve hasPassword flags that aren't included in the payload from
|
||
// HostManager (the manager strips them — backend recomputes on next
|
||
// list_ssh_hosts; we keep them locally so the badge doesn't flicker).
|
||
setHosts((prev) =>
|
||
next.map((h) => {
|
||
const hp = h.hasPassword ?? prev.find((p) => p.id === h.id)?.hasPassword;
|
||
return hp === undefined ? h : { ...h, hasPassword: hp };
|
||
}),
|
||
);
|
||
saveSshHosts(next).catch((e) =>
|
||
console.warn("saveSshHosts failed:", e),
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const savePassword = useCallback((hostId: string, password: string) => {
|
||
setHostPassword(hostId, password).then(
|
||
() =>
|
||
setHosts((prev) =>
|
||
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: true } : h)),
|
||
),
|
||
(e) => console.warn("setHostPassword failed:", e),
|
||
);
|
||
}, []);
|
||
|
||
const clearPassword = useCallback((hostId: string) => {
|
||
deleteHostPassword(hostId).then(
|
||
() =>
|
||
setHosts((prev) =>
|
||
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: false } : h)),
|
||
),
|
||
(e) => console.warn("deleteHostPassword failed:", e),
|
||
);
|
||
}, []);
|
||
|
||
// ---- global keyboard shortcuts ------------------------------------------
|
||
// Capture phase beats xterm.js's own keystroke handlers. We intentionally
|
||
// don't intercept when the user is typing into a regular <input> (label
|
||
// edits etc.) — but DO intercept when focus is in the xterm textarea,
|
||
// which is what makes shortcuts work while a terminal is focused.
|
||
const kbdStateRef = useRef({ activeLeafId, tree });
|
||
useEffect(() => {
|
||
kbdStateRef.current = { activeLeafId, tree };
|
||
});
|
||
useEffect(() => {
|
||
const DIR_MAP: Record<string, Direction | undefined> = {
|
||
arrowleft: "left",
|
||
arrowright: "right",
|
||
arrowup: "up",
|
||
arrowdown: "down",
|
||
};
|
||
|
||
function shouldIgnore(): boolean {
|
||
const ae = document.activeElement as HTMLElement | null;
|
||
if (!ae) return false;
|
||
const tag = ae.tagName;
|
||
if (tag !== "INPUT" && tag !== "TEXTAREA") return false;
|
||
if (ae.classList.contains("xterm-helper-textarea")) return false;
|
||
return true;
|
||
}
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (shouldIgnore()) return;
|
||
const ctrl = e.ctrlKey || e.metaKey;
|
||
const shift = e.shiftKey;
|
||
const alt = e.altKey;
|
||
const key = e.key.toLowerCase();
|
||
const { activeLeafId, tree } = kbdStateRef.current;
|
||
|
||
// F1 — toggle help overlay
|
||
if (key === "f1") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setHelpOpen((v) => !v);
|
||
return;
|
||
}
|
||
|
||
// Ctrl+K — palette
|
||
if (ctrl && !shift && !alt && key === "k") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setPaletteOpen((v) => !v);
|
||
return;
|
||
}
|
||
|
||
// Ctrl+Shift+U — usage panel
|
||
if (ctrl && shift && !alt && key === "u") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setUsagePanelOpen((v) => !v);
|
||
return;
|
||
}
|
||
|
||
// Ctrl+Shift+Alt+B — global broadcast all/none
|
||
if (ctrl && shift && alt && key === "b") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
let anyOn = false;
|
||
for (const leaf of walkLeaves(tree)) {
|
||
if (leaf.broadcast) {
|
||
anyOn = true;
|
||
break;
|
||
}
|
||
}
|
||
setTree((t) => setAllBroadcast(t, !anyOn));
|
||
return;
|
||
}
|
||
|
||
// Ctrl[+Shift]+= / - / 0 — terminal font size. Browser convention:
|
||
// unshifted touches the active pane, Shift escalates to every pane.
|
||
// Match on e.code so the bindings work the same across layouts (and
|
||
// regardless of whether Shift turns "=" into "+" etc.).
|
||
if (ctrl && !alt && (e.code === "Equal" || e.code === "Minus" || e.code === "Digit0")) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const delta =
|
||
e.code === "Equal" ? 1 : e.code === "Minus" ? -1 : null;
|
||
if (shift) {
|
||
setTree((t) => adjustAllFontSizes(t, delta));
|
||
} else if (activeLeafId) {
|
||
setTree((t) => adjustFontSize(t, activeLeafId, delta));
|
||
}
|
||
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;
|
||
|
||
// Ctrl+Shift+Arrow — pane navigation
|
||
const dir = DIR_MAP[key];
|
||
if (dir) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const layout = flattenLayout(tree);
|
||
if (!activeLeafId) {
|
||
const first = layout.leaves[0]?.leaf.id;
|
||
if (first) setActiveLeafId(first);
|
||
return;
|
||
}
|
||
const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir);
|
||
if (nextId) setActiveLeafId(nextId);
|
||
return;
|
||
}
|
||
|
||
if (!activeLeafId) return;
|
||
|
||
if (key === "e") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
split(activeLeafId, "h");
|
||
} else if (key === "o") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
split(activeLeafId, "v");
|
||
} else if (key === "w") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
close(activeLeafId);
|
||
} else if (key === "b") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleBroadcast(activeLeafId);
|
||
} else if (key === "p") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
promoteActive(activeLeafId);
|
||
}
|
||
}
|
||
|
||
window.addEventListener("keydown", onKey, true);
|
||
return () => window.removeEventListener("keydown", onKey, true);
|
||
}, [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
|
||
// after the freshly-mounted XtermPane completes its spawn round-trip and
|
||
// calls back into registerPaneId.
|
||
const pendingPaneRegistrations = useRef<Map<NodeId, (paneId: PaneId) => void>>(
|
||
new Map(),
|
||
);
|
||
|
||
const registerPaneId = useCallback(
|
||
(leafId: NodeId, paneId: PaneId | null) => {
|
||
if (paneId == null) {
|
||
paneIdByLeafRef.current.delete(leafId);
|
||
return;
|
||
}
|
||
paneIdByLeafRef.current.set(leafId, paneId);
|
||
// One-shot: now that the pane has registered, the transferred-id
|
||
// hint is consumed.
|
||
transferredPaneIdsRef.current.delete(leafId);
|
||
const waiter = pendingPaneRegistrations.current.get(leafId);
|
||
if (waiter) {
|
||
pendingPaneRegistrations.current.delete(leafId);
|
||
waiter(paneId);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const getInitialPaneIdFor = useCallback(
|
||
(leafId: NodeId): PaneId | undefined =>
|
||
transferredPaneIdsRef.current.get(leafId),
|
||
[],
|
||
);
|
||
|
||
/** Pop the given leaf into a fresh top-level window. The source's
|
||
* XtermPane will unmount as the leaf leaves this window's tree;
|
||
* markPaneTransferring keeps the underlying PTY alive until the new
|
||
* window's XtermPane adopts it via existingPaneId. */
|
||
const moveToNewWindow = useCallback(
|
||
async (leafId: NodeId) => {
|
||
const leaf = findLeaf(treeRef.current, leafId);
|
||
if (!leaf || leaf.kind !== "leaf") {
|
||
notify("Cannot move — pane not found");
|
||
return;
|
||
}
|
||
// The pane's id is registered only after its XtermPane finishes the
|
||
// async spawn/adopt round-trip. If the user drags out a pane that's
|
||
// still completing that (e.g. just after a shell-swap, or a pane in a
|
||
// freshly-detached window), wait for registration instead of failing
|
||
// outright. Resolves immediately if already registered.
|
||
let paneId = paneIdByLeafRef.current.get(leafId);
|
||
if (paneId == null) {
|
||
try {
|
||
paneId = await waitForPaneRegistration(leafId, 5000);
|
||
} catch {
|
||
notify("Cannot move — PTY not ready yet");
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await markPaneTransferring(paneId);
|
||
} catch (e) {
|
||
notify(`mark_pane_transferring failed: ${e}`);
|
||
return;
|
||
}
|
||
|
||
// Snapshot the leaf BEFORE removing — closeLeaf may produce a tree
|
||
// where this leaf is no longer present, breaking findLeaf later.
|
||
const leafJson = JSON.stringify(leaf);
|
||
const workspaceName = leaf.label ?? `Pane ${paneId}`;
|
||
|
||
// Remove from current tree (sibling promotes naturally via closeLeaf).
|
||
// If this leaf was the entire tree, fall back to a fresh default so
|
||
// the source workspace never becomes empty (matches close behavior).
|
||
setTree(
|
||
(t) =>
|
||
closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)),
|
||
);
|
||
paneIdByLeafRef.current.delete(leafId);
|
||
setActiveLeafByWorkspace((prev) => {
|
||
const wsId = currentWorkspaceIdRef.current;
|
||
if (!wsId) return prev;
|
||
if (prev.get(wsId) !== leafId) return prev;
|
||
const m = new Map(prev);
|
||
m.set(wsId, null);
|
||
return m;
|
||
});
|
||
|
||
try {
|
||
await createPaneWindow({ leafJson, paneId, workspaceName });
|
||
} catch (e) {
|
||
notify(`Failed to open new window: ${e}`);
|
||
// The leaf is already gone from our tree and the PTY is orphaned
|
||
// in transferring state. Drop the refcount so a manual kill could
|
||
// eventually succeed; but the leaf no longer exists in any tree.
|
||
void claimPane(paneId).catch(() => {});
|
||
}
|
||
},
|
||
[defaultShell, notify],
|
||
);
|
||
|
||
/** Insert a new leaf into the tree from a SpawnSpec — used by the MCP
|
||
* spawn_pane and connect_host handlers. Returns the new leaf's id
|
||
* (caller awaits waitForPaneRegistration on it for the paneId).
|
||
* - parentLeafId: defaults to active leaf, then first leaf in layout.
|
||
* - orientation: "h" / "v" / undefined; undefined picks smart-orient by
|
||
* parent pane aspect (matches the titlebar "+" button's behaviour).
|
||
* - New leaf is auto-marked mcpAllow=true so Claude can immediately
|
||
* interact with the pane it just spawned. */
|
||
const spawnNewLeafFromSpec = useCallback(
|
||
async (
|
||
spec: SpawnSpec,
|
||
parentLeafId: string | undefined,
|
||
orientationArg: string | undefined,
|
||
): Promise<NodeId> => {
|
||
const layout = flattenLayout(treeRef.current);
|
||
const parentId =
|
||
parentLeafId ?? activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
|
||
if (!parentId) throw new Error("no pane available to split off");
|
||
const parent = findLeaf(treeRef.current, parentId);
|
||
if (!parent || parent.kind !== "leaf") {
|
||
throw new Error(`parent leaf not found: ${parentId}`);
|
||
}
|
||
|
||
let orient: Orientation;
|
||
if (orientationArg === "h" || orientationArg === "v") {
|
||
orient = orientationArg;
|
||
} else if (orientationArg == null) {
|
||
// Smart-orient: split along the longer side of the parent's pane.
|
||
const container = paneWrapRef.current;
|
||
const slot = layout.leaves.find((s) => s.leaf.id === parentId);
|
||
if (container && slot) {
|
||
const rect = container.getBoundingClientRect();
|
||
const paneW = slot.box.width * rect.width;
|
||
const paneH = slot.box.height * rect.height;
|
||
orient = paneW >= paneH ? "h" : "v";
|
||
} else {
|
||
orient = "h"; // safe fallback
|
||
}
|
||
} else {
|
||
throw new Error(
|
||
`invalid orientation: ${orientationArg} (expected "h" or "v")`,
|
||
);
|
||
}
|
||
|
||
// For SSH spawns, the auto-allow safeguard decides whether the new
|
||
// pane starts MCP-allowed (Claude can interact immediately) or
|
||
// mcpAllow=off (user must explicitly toggle 🤖 to grant access).
|
||
// Local shells (WSL / PowerShell) are auto-allowed unconditionally.
|
||
let mcpAllow = true;
|
||
if (spec.kind === "ssh") {
|
||
try {
|
||
const policy = await mcpPolicyLoad();
|
||
mcpAllow = policy.sshSafeguards.autoAllowSpawnedSsh;
|
||
} catch (e) {
|
||
console.warn("policy load failed during ssh spawn, defaulting mcpAllow=false:", e);
|
||
mcpAllow = false;
|
||
}
|
||
}
|
||
|
||
const id = newId();
|
||
const newLeafNode: LeafNode = {
|
||
kind: "leaf",
|
||
id,
|
||
shellKind: spec.kind,
|
||
mcpAllow,
|
||
...(spec.kind === "wsl"
|
||
? { distro: spec.distro, cwd: spec.cwd }
|
||
: {}),
|
||
...(spec.kind === "ssh" && spec.hostId
|
||
? { sshHostId: spec.hostId }
|
||
: {}),
|
||
};
|
||
setTree((t) => splitLeafWith(t, parentId, orient, newLeafNode));
|
||
return id;
|
||
},
|
||
[activeLeafId],
|
||
);
|
||
|
||
/** Resolves to the paneId once XtermPane finishes mounting and the
|
||
* spawn_pane Tauri command returns. Rejects after timeoutMs. */
|
||
function waitForPaneRegistration(
|
||
leafId: NodeId,
|
||
timeoutMs = 5000,
|
||
): Promise<PaneId> {
|
||
return new Promise((resolve, reject) => {
|
||
const existing = paneIdByLeafRef.current.get(leafId);
|
||
if (existing != null) {
|
||
resolve(existing);
|
||
return;
|
||
}
|
||
pendingPaneRegistrations.current.set(leafId, resolve);
|
||
setTimeout(() => {
|
||
if (pendingPaneRegistrations.current.has(leafId)) {
|
||
pendingPaneRegistrations.current.delete(leafId);
|
||
reject(
|
||
new Error(
|
||
`spawn timed out after ${timeoutMs}ms — pane never registered`,
|
||
),
|
||
);
|
||
}
|
||
}, timeoutMs);
|
||
});
|
||
}
|
||
|
||
const broadcastFrom = useCallback(
|
||
(originLeafId: NodeId, dataB64: string) => {
|
||
let peers = 0;
|
||
for (const leaf of walkLeaves(treeRef.current)) {
|
||
if (leaf.id === originLeafId) continue;
|
||
if (!leaf.broadcast) continue;
|
||
const paneId = paneIdByLeafRef.current.get(leaf.id);
|
||
if (paneId == null) continue;
|
||
peers++;
|
||
writeToPane(paneId, dataB64).catch((e) =>
|
||
console.warn("broadcast write failed:", e),
|
||
);
|
||
}
|
||
if (peers > 0) {
|
||
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const dismissNotification = useCallback((id: number) => {
|
||
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||
}, []);
|
||
|
||
// ---- per-pane idle aggregation (replaces toast spam) --------------------
|
||
const [idleLeafIds, setIdleLeafIds] = useState<Set<NodeId>>(() => new Set());
|
||
const reportLeafIdle = useCallback((leafId: NodeId, idle: boolean) => {
|
||
setIdleLeafIds((prev) => {
|
||
if (idle && prev.has(leafId)) return prev;
|
||
if (!idle && !prev.has(leafId)) return prev;
|
||
const next = new Set(prev);
|
||
if (idle) next.add(leafId);
|
||
else next.delete(leafId);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// ---- header-drag swap ---------------------------------------------------
|
||
const [dragSourceId, setDragSourceId] = useState<NodeId | null>(null);
|
||
const [dragOverId, setDragOverId] = useState<NodeId | null>(null);
|
||
const beginHeaderDrag = useCallback((leafId: NodeId) => {
|
||
setDragSourceId(leafId);
|
||
setDragOverId(null);
|
||
}, []);
|
||
const setHeaderDragOver = useCallback((leafId: NodeId | null) => {
|
||
setDragOverId(leafId);
|
||
}, []);
|
||
const endHeaderDrag = useCallback(
|
||
(commitSwap: boolean) => {
|
||
if (
|
||
commitSwap &&
|
||
dragSourceId &&
|
||
dragOverId &&
|
||
dragSourceId !== dragOverId
|
||
) {
|
||
setTree((t) => swapLeaves(t, dragSourceId, dragOverId));
|
||
}
|
||
setDragSourceId(null);
|
||
setDragOverId(null);
|
||
},
|
||
[dragSourceId, dragOverId],
|
||
);
|
||
|
||
const orch = useMemo<Orchestration>(
|
||
() => ({
|
||
activeLeafId,
|
||
distros,
|
||
hosts,
|
||
split,
|
||
close,
|
||
setShell,
|
||
setLabel,
|
||
toggleBroadcast,
|
||
toggleMcpAllow,
|
||
openHostManager,
|
||
setActive,
|
||
navigateTo,
|
||
registerPaneId,
|
||
broadcastFrom,
|
||
notify,
|
||
dragSourceId,
|
||
dragOverId,
|
||
beginHeaderDrag,
|
||
setHeaderDragOver,
|
||
endHeaderDrag,
|
||
reportLeafIdle,
|
||
moveToNewWindow,
|
||
getInitialPaneIdFor,
|
||
}),
|
||
[
|
||
activeLeafId,
|
||
distros,
|
||
hosts,
|
||
split,
|
||
close,
|
||
setShell,
|
||
setLabel,
|
||
toggleBroadcast,
|
||
toggleMcpAllow,
|
||
openHostManager,
|
||
setActive,
|
||
navigateTo,
|
||
registerPaneId,
|
||
broadcastFrom,
|
||
notify,
|
||
dragSourceId,
|
||
dragOverId,
|
||
beginHeaderDrag,
|
||
setHeaderDragOver,
|
||
endHeaderDrag,
|
||
reportLeafIdle,
|
||
moveToNewWindow,
|
||
getInitialPaneIdFor,
|
||
],
|
||
);
|
||
|
||
// ---- MCP mirror push -----------------------------------------------------
|
||
// Whenever the tree, hosts, or active selection change AND the MCP server
|
||
// is running, push a fresh mirror down to the backend. Per-leaf mcpAllow
|
||
// gates whether each leaf appears in the mirror (default-deny).
|
||
//
|
||
// Multi-window scoping: only the MAIN window pushes the mirror. Detached
|
||
// windows have their own current-workspace tree but Claude sees ONE
|
||
// workspace surface — main's current tab. Otherwise two windows would
|
||
// overwrite each other's mirrors on every keystroke and Claude's view
|
||
// would flap unpredictably.
|
||
const allowedPaneCount = useMemo(
|
||
() => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length,
|
||
[tree],
|
||
);
|
||
useEffect(() => {
|
||
if (!IS_MAIN_WINDOW) return;
|
||
if (!mcpStatus.running) return;
|
||
const leaves: Record<string, McpMirroredLeaf> = {};
|
||
for (const leaf of walkLeaves(tree)) {
|
||
if (!leaf.mcpAllow) continue;
|
||
leaves[leaf.id] = {
|
||
paneId: paneIdByLeafRef.current.get(leaf.id) ?? null,
|
||
label: leaf.label,
|
||
shellKind: leaf.shellKind,
|
||
distro: leaf.distro,
|
||
sshHostId: leaf.sshHostId,
|
||
broadcast: !!leaf.broadcast,
|
||
active: activeLeafId === leaf.id,
|
||
};
|
||
}
|
||
const mirroredHosts: McpMirroredHost[] = hosts.map((h) => ({
|
||
id: h.id,
|
||
label: h.label,
|
||
hostname: h.hostname,
|
||
user: h.user,
|
||
port: h.port,
|
||
hasPassword: !!h.hasPassword,
|
||
}));
|
||
const mirror: McpMirror = {
|
||
layoutJson: serialize(tree),
|
||
leaves,
|
||
hosts: mirroredHosts,
|
||
};
|
||
mcpUpdateState(mirror).catch((e) =>
|
||
console.warn("mcpUpdateState failed:", e),
|
||
);
|
||
}, [mcpStatus.running, tree, hosts, activeLeafId]);
|
||
|
||
// ---- MCP audit log -------------------------------------------------------
|
||
// Subscribed at App level so events received while the panel is closed (or
|
||
// while the user is on another tab) still land in the ring buffer. The
|
||
// McpPanel consumes the entries via props.
|
||
const AUDIT_RING_CAP = 200;
|
||
const [auditEntries, setAuditEntries] = useState<McpAuditEntry[]>([]);
|
||
const clearAudit = useCallback(() => setAuditEntries([]), []);
|
||
|
||
useEffect(() => {
|
||
// StrictMode double-mounts effects in dev; the listen() Promise may
|
||
// resolve AFTER the first cleanup runs. Guard with a cancelled flag so
|
||
// a late-resolving subscription gets immediately torn down instead of
|
||
// leaking into the second mount (which would double every event).
|
||
let cancelled = false;
|
||
let unlisten: (() => void) | undefined;
|
||
void listen<McpAuditEntry>("mcp://audit", (e) => {
|
||
setAuditEntries((prev) => {
|
||
const next = [e.payload, ...prev];
|
||
return next.length > AUDIT_RING_CAP ? next.slice(0, AUDIT_RING_CAP) : next;
|
||
});
|
||
}).then((fn) => {
|
||
if (cancelled) fn();
|
||
else unlisten = fn;
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, []);
|
||
|
||
// ---- MCP action dispatcher -----------------------------------------------
|
||
// Backend tools that mutate workspace state emit "mcp://request" events with
|
||
// a requestId + args. We resolve them via mcp_action_reply(requestId, Ok|Err).
|
||
// When needsConfirm is true, a modal queues up first; user Accept = run,
|
||
// Reject = reply with Err. Per-tool handlers live in `runMcpHandler` below.
|
||
|
||
interface ConfirmEntry extends McpConfirmSpec {
|
||
resolve: (accept: boolean) => void;
|
||
}
|
||
const [confirmQueue, setConfirmQueue] = useState<ConfirmEntry[]>([]);
|
||
|
||
const requestConfirm = useCallback(
|
||
(spec: McpConfirmSpec): Promise<boolean> =>
|
||
new Promise((resolve) =>
|
||
setConfirmQueue((q) => [...q, { ...spec, resolve }]),
|
||
),
|
||
[],
|
||
);
|
||
|
||
const dismissConfirm = useCallback((accept: boolean) => {
|
||
setConfirmQueue((q) => {
|
||
const [head, ...rest] = q;
|
||
if (head) head.resolve(accept);
|
||
return rest;
|
||
});
|
||
}, []);
|
||
|
||
// "Always allow" from the confirm modal: appends the bare tool name to
|
||
// the policy's allow bucket so future calls of this tool skip the prompt.
|
||
// Idempotent — duplicate adds are no-ops.
|
||
const alwaysAllowTool = useCallback(async (tool: string) => {
|
||
try {
|
||
const policy = await mcpPolicyLoad();
|
||
if (!policy.permissions.allow.includes(tool)) {
|
||
policy.permissions.allow.push(tool);
|
||
await mcpPolicySave(policy);
|
||
notify(`Added "${tool}" to MCP allow list — future calls won't prompt`);
|
||
}
|
||
} catch (e) {
|
||
notify(`Failed to update MCP policy: ${e}`);
|
||
}
|
||
}, [notify]);
|
||
|
||
// Per-tool handler. Each branch validates args, applies the mutation via the
|
||
// same setters the UI uses, and returns a JSON-serialisable payload. Throws
|
||
// on validation failure — caller converts to Err reply.
|
||
const runMcpHandler = useCallback(
|
||
async (tool: string, args: unknown): Promise<{ payload: unknown; summary: string }> => {
|
||
switch (tool) {
|
||
case "set_label": {
|
||
const a = args as { leafId?: string; label?: string };
|
||
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
||
if (typeof a.label !== "string") throw new Error("missing label");
|
||
const leaf = findLeaf(treeRef.current, a.leafId);
|
||
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
|
||
const before = leaf.label ?? "(unlabelled)";
|
||
setLabel(a.leafId, a.label || undefined);
|
||
const after = a.label || "(cleared)";
|
||
return {
|
||
payload: { leafId: a.leafId, label: a.label },
|
||
summary: `Rename pane "${before}" → "${after}"`,
|
||
};
|
||
}
|
||
case "write_pane": {
|
||
const a = args as { leafId?: string; text?: string };
|
||
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
||
if (typeof a.text !== "string") throw new Error("missing text");
|
||
const leaf = findLeaf(treeRef.current, a.leafId);
|
||
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
|
||
const paneId = paneIdByLeafRef.current.get(a.leafId);
|
||
if (paneId == null) throw new Error(`no live pane for leaf ${a.leafId}`);
|
||
// UTF-8 encode → base64 (matches the existing writeToPane wire shape).
|
||
const bytes = new TextEncoder().encode(a.text);
|
||
let binary = "";
|
||
for (let i = 0; i < bytes.byteLength; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
const b64 = btoa(binary);
|
||
await writeToPane(paneId, b64);
|
||
const labelStr = leaf.label ?? a.leafId.slice(0, 8);
|
||
return {
|
||
payload: { leafId: a.leafId, bytesWritten: bytes.length },
|
||
summary: `Send to pane "${labelStr}": ${truncateForSummary(a.text)}`,
|
||
};
|
||
}
|
||
case "spawn_pane": {
|
||
// Backend McpSpawnSpec only contains Wsl / Powershell — SSH is
|
||
// routed through connect_host instead. The cast tolerates the
|
||
// wider frontend SpawnSpec union; the kind discriminant is what
|
||
// matters at runtime.
|
||
const a = args as {
|
||
spec?: SpawnSpec;
|
||
parentLeafId?: string;
|
||
orientation?: string;
|
||
};
|
||
if (!a.spec || typeof a.spec !== "object") {
|
||
throw new Error("missing spec");
|
||
}
|
||
const newLeafId = await spawnNewLeafFromSpec(
|
||
a.spec,
|
||
a.parentLeafId,
|
||
a.orientation,
|
||
);
|
||
// 15s covers a cold WSL distro start (~3-5s typical, longer if
|
||
// the distro hasn't been used recently).
|
||
const paneId = await waitForPaneRegistration(newLeafId, 15000);
|
||
return {
|
||
payload: { leafId: newLeafId, paneId },
|
||
summary: `Spawn ${describeSpec(a.spec)} pane`,
|
||
};
|
||
}
|
||
case "connect_host": {
|
||
const a = args as {
|
||
hostId?: string;
|
||
parentLeafId?: string;
|
||
orientation?: string;
|
||
};
|
||
if (typeof a.hostId !== "string") throw new Error("missing hostId");
|
||
const host = hosts.find((h) => h.id === a.hostId);
|
||
if (!host) throw new Error(`unknown host_id: ${a.hostId}`);
|
||
const newLeafId = await spawnNewLeafFromSpec(
|
||
{ kind: "ssh", host: host.hostname, hostId: host.id },
|
||
a.parentLeafId,
|
||
a.orientation,
|
||
);
|
||
// 30s covers SSH handshake + password auth on a slow or
|
||
// first-time connection.
|
||
const paneId = await waitForPaneRegistration(newLeafId, 30000);
|
||
return {
|
||
payload: { leafId: newLeafId, paneId, hostId: host.id },
|
||
summary: `Connect SSH to "${host.label}" (${host.hostname})`,
|
||
};
|
||
}
|
||
case "close_pane": {
|
||
const a = args as { leafId?: string };
|
||
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
||
const leaf = findLeaf(treeRef.current, a.leafId);
|
||
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
|
||
const labelStr = leaf.label ?? a.leafId.slice(0, 8);
|
||
close(a.leafId);
|
||
return {
|
||
payload: { leafId: a.leafId },
|
||
summary: `Close pane "${labelStr}"`,
|
||
};
|
||
}
|
||
case "swap_panes": {
|
||
const a = args as { leafA?: string; leafB?: string };
|
||
if (typeof a.leafA !== "string") throw new Error("missing leafA");
|
||
if (typeof a.leafB !== "string") throw new Error("missing leafB");
|
||
if (a.leafA === a.leafB) throw new Error("leafA and leafB are the same");
|
||
const lA = findLeaf(treeRef.current, a.leafA);
|
||
const lB = findLeaf(treeRef.current, a.leafB);
|
||
if (!lA || lA.kind !== "leaf") throw new Error(`leaf not found: ${a.leafA}`);
|
||
if (!lB || lB.kind !== "leaf") throw new Error(`leaf not found: ${a.leafB}`);
|
||
const labelA = lA.label ?? a.leafA.slice(0, 8);
|
||
const labelB = lB.label ?? a.leafB.slice(0, 8);
|
||
setTree((t) => swapLeaves(t, a.leafA!, a.leafB!));
|
||
return {
|
||
payload: { leafA: a.leafA, leafB: a.leafB },
|
||
summary: `Swap panes "${labelA}" ↔ "${labelB}"`,
|
||
};
|
||
}
|
||
case "promote_pane": {
|
||
const a = args as { leafId?: string };
|
||
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
||
const leaf = findLeaf(treeRef.current, a.leafId);
|
||
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
|
||
const next = promoteLeaf(treeRef.current, a.leafId);
|
||
if (next === null) {
|
||
throw new Error(
|
||
"pane can't be promoted (no perpendicular split above it)",
|
||
);
|
||
}
|
||
setTree(next);
|
||
return {
|
||
payload: { leafId: a.leafId },
|
||
summary: `Promote pane "${leaf.label ?? a.leafId.slice(0, 8)}" up one level`,
|
||
};
|
||
}
|
||
case "add_host": {
|
||
const a = args as {
|
||
label?: string;
|
||
hostname?: string;
|
||
user?: string;
|
||
port?: number;
|
||
identityFile?: string;
|
||
jumpHost?: string;
|
||
extraArgs?: string[];
|
||
};
|
||
if (typeof a.hostname !== "string" || !a.hostname.trim()) {
|
||
throw new Error("missing hostname");
|
||
}
|
||
const hostname = a.hostname.trim();
|
||
const label = (a.label && a.label.trim()) || hostname;
|
||
const id = newId();
|
||
const newHost: SshHost = {
|
||
id,
|
||
label,
|
||
hostname,
|
||
...(a.user ? { user: a.user } : {}),
|
||
...(a.port ? { port: a.port } : {}),
|
||
...(a.identityFile ? { identityFile: a.identityFile } : {}),
|
||
...(a.jumpHost ? { jumpHost: a.jumpHost } : {}),
|
||
...(a.extraArgs && a.extraArgs.length > 0
|
||
? { extraArgs: a.extraArgs }
|
||
: {}),
|
||
};
|
||
const next = [...hosts, newHost];
|
||
setHosts(next);
|
||
await saveSshHosts(next);
|
||
return {
|
||
payload: { hostId: id, label, hostname },
|
||
summary: `Save SSH host "${label}" (${a.user ? `${a.user}@` : ""}${hostname}${a.port ? `:${a.port}` : ""})`,
|
||
};
|
||
}
|
||
case "delete_host": {
|
||
const a = args as { hostId?: string };
|
||
if (typeof a.hostId !== "string") throw new Error("missing hostId");
|
||
const host = hosts.find((h) => h.id === a.hostId);
|
||
if (!host) throw new Error(`unknown host_id: ${a.hostId}`);
|
||
const next = hosts.filter((h) => h.id !== a.hostId);
|
||
setHosts(next);
|
||
// save_ssh_hosts on the backend sweeps orphan keyring credentials
|
||
// for any id that disappears from the list, so no separate
|
||
// delete_host_password call is needed.
|
||
await saveSshHosts(next);
|
||
return {
|
||
payload: { hostId: a.hostId, label: host.label },
|
||
summary: `Delete SSH host "${host.label}" (${host.hostname})`,
|
||
};
|
||
}
|
||
case "apply_preset": {
|
||
const a = args as { name?: string; allowDrops?: boolean };
|
||
const presetMap: Record<string, (d: Partial<LeafNode>) => TreeNode> = {
|
||
single: presetSingle,
|
||
two_columns: presetTwoColumns,
|
||
three_columns: presetThreeColumns,
|
||
two_rows: presetTwoRows,
|
||
two_by_two: presetTwoByTwo,
|
||
};
|
||
const make = a.name ? presetMap[a.name] : undefined;
|
||
if (!make) {
|
||
throw new Error(
|
||
`unknown preset: ${a.name} (valid: single, two_columns, three_columns, two_rows, two_by_two)`,
|
||
);
|
||
}
|
||
const { tree: nextTree, dropped } = reshapeToPreset(
|
||
treeRef.current,
|
||
make,
|
||
defaultShellAsLeafProps(defaultShell),
|
||
);
|
||
if (dropped.length > 0 && !a.allowDrops) {
|
||
const labels = dropped
|
||
.map((id) => {
|
||
const l = findLeaf(treeRef.current, id);
|
||
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
|
||
})
|
||
.join(", ");
|
||
throw new Error(
|
||
`would drop ${dropped.length} pane(s) (${labels}); pass allow_drops=true to confirm`,
|
||
);
|
||
}
|
||
for (const id of dropped) {
|
||
const paneId = paneIdByLeafRef.current.get(id);
|
||
if (paneId != null) {
|
||
void killPane(paneId).catch((e) =>
|
||
console.warn("killPane failed:", e),
|
||
);
|
||
paneIdByLeafRef.current.delete(id);
|
||
}
|
||
}
|
||
if (activeLeafId && dropped.includes(activeLeafId)) {
|
||
setActiveLeafId(null);
|
||
}
|
||
setTree(nextTree);
|
||
return {
|
||
payload: { name: a.name, dropped: dropped.length, droppedLeafIds: dropped },
|
||
summary:
|
||
dropped.length > 0
|
||
? `Reshape to ${a.name} (closes ${dropped.length} pane${dropped.length === 1 ? "" : "s"})`
|
||
: `Reshape to ${a.name}`,
|
||
};
|
||
}
|
||
default:
|
||
throw new Error(`unsupported MCP tool: ${tool}`);
|
||
}
|
||
},
|
||
[setLabel, close, defaultShell, activeLeafId, hosts, spawnNewLeafFromSpec],
|
||
);
|
||
|
||
// The summary string for the confirm modal needs access to the leaf
|
||
// metadata, so we compute it up-front by partially running the handler
|
||
// logic (without mutating). For now we just rebuild it inline per tool;
|
||
// when more tools land this should split out.
|
||
const buildConfirmInfo = useCallback(
|
||
(tool: string, args: unknown): { summary: string; ssh?: { hostLabel: string } } => {
|
||
function leafLabel(id: string | undefined): string {
|
||
if (!id) return "(unknown)";
|
||
const l = findLeaf(treeRef.current, id);
|
||
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
|
||
}
|
||
function sshContextForLeaf(id: string | undefined): { hostLabel: string } | undefined {
|
||
if (!id) return undefined;
|
||
const l = findLeaf(treeRef.current, id);
|
||
if (!l || l.kind !== "leaf" || l.shellKind !== "ssh") return undefined;
|
||
const host = hosts.find((h) => h.id === l.sshHostId);
|
||
return { hostLabel: host ? `${host.label} (${host.hostname})` : (l.sshHostId ?? "?") };
|
||
}
|
||
switch (tool) {
|
||
case "set_label": {
|
||
const a = args as { leafId?: string; label?: string };
|
||
return {
|
||
summary: `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`,
|
||
};
|
||
}
|
||
case "write_pane": {
|
||
const a = args as { leafId?: string; text?: string };
|
||
return {
|
||
summary: `Send to pane "${leafLabel(a.leafId)}": ${truncateForSummary(a.text ?? "")}`,
|
||
ssh: sshContextForLeaf(a.leafId),
|
||
};
|
||
}
|
||
case "spawn_pane": {
|
||
const a = args as { spec?: SpawnSpec };
|
||
const summary = a.spec ? `Spawn ${describeSpec(a.spec)} pane` : "Spawn pane";
|
||
// Bind the narrowed SSH variant to a local so the closure inside
|
||
// hosts.find() doesn't lose the discriminant. Using a.spec! here
|
||
// would compile under tsc --noEmit but fail under tsc -b.
|
||
let ssh: { hostLabel: string } | undefined;
|
||
if (a.spec && a.spec.kind === "ssh") {
|
||
const sshSpec = a.spec;
|
||
ssh = {
|
||
hostLabel: sshSpec.hostId
|
||
? hosts.find((h) => h.id === sshSpec.hostId)?.label ?? sshSpec.host
|
||
: sshSpec.host,
|
||
};
|
||
}
|
||
return { summary, ssh };
|
||
}
|
||
case "connect_host": {
|
||
const a = args as { hostId?: string };
|
||
const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null;
|
||
const name = host ? `"${host.label}" (${host.hostname})` : a.hostId;
|
||
return {
|
||
summary: `Connect SSH to ${name}`,
|
||
ssh: host ? { hostLabel: `${host.label} (${host.hostname})` } : undefined,
|
||
};
|
||
}
|
||
case "close_pane": {
|
||
const a = args as { leafId?: string };
|
||
return {
|
||
summary: `Close pane "${leafLabel(a.leafId)}"`,
|
||
ssh: sshContextForLeaf(a.leafId),
|
||
};
|
||
}
|
||
case "swap_panes": {
|
||
const a = args as { leafA?: string; leafB?: string };
|
||
return {
|
||
summary: `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`,
|
||
};
|
||
}
|
||
case "promote_pane": {
|
||
const a = args as { leafId?: string };
|
||
return {
|
||
summary: `Promote pane "${leafLabel(a.leafId)}" up one level`,
|
||
};
|
||
}
|
||
case "apply_preset": {
|
||
const a = args as { name?: string; allowDrops?: boolean };
|
||
const suffix = a.allowDrops ? " (drops allowed)" : "";
|
||
return { summary: `Reshape workspace to ${a.name}${suffix}` };
|
||
}
|
||
case "add_host": {
|
||
const a = args as {
|
||
label?: string;
|
||
hostname?: string;
|
||
user?: string;
|
||
port?: number;
|
||
};
|
||
const label = (a.label && a.label.trim()) || a.hostname || "(host)";
|
||
const conn = `${a.user ? `${a.user}@` : ""}${a.hostname ?? ""}${a.port ? `:${a.port}` : ""}`;
|
||
return { summary: `Save SSH host "${label}" (${conn})` };
|
||
}
|
||
case "delete_host": {
|
||
const a = args as { hostId?: string };
|
||
const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null;
|
||
const name = host ? `"${host.label}" (${host.hostname})` : a.hostId;
|
||
return { summary: `Delete SSH host ${name}` };
|
||
}
|
||
default:
|
||
return { summary: `Run ${tool}` };
|
||
}
|
||
},
|
||
[hosts],
|
||
);
|
||
|
||
useEffect(() => {
|
||
// Only the main window handles MCP requests — paneIdByLeafRef is
|
||
// per-window so a request targeting a leaf in another window would
|
||
// fail anyway. Keeps responsibility clean: MCP sees main, period.
|
||
if (!IS_MAIN_WINDOW) return;
|
||
let cancelled = false;
|
||
let unlisten: (() => void) | undefined;
|
||
void onMcpRequest(async (req: McpActionRequest) => {
|
||
try {
|
||
if (req.needsConfirm) {
|
||
const info = buildConfirmInfo(req.tool, req.args);
|
||
const ok = await requestConfirm({
|
||
tool: req.tool,
|
||
args: req.args,
|
||
reason: req.reason,
|
||
summary: info.summary,
|
||
ssh: info.ssh,
|
||
});
|
||
if (!ok) {
|
||
await mcpActionReply(req.requestId, { Err: "user rejected" });
|
||
return;
|
||
}
|
||
}
|
||
const { payload } = await runMcpHandler(req.tool, req.args);
|
||
await mcpActionReply(req.requestId, { Ok: payload });
|
||
} catch (err) {
|
||
await mcpActionReply(req.requestId, { Err: String(err) });
|
||
}
|
||
}).then((fn) => {
|
||
if (cancelled) fn();
|
||
else unlisten = fn;
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, [runMcpHandler, requestConfirm, buildConfirmInfo]);
|
||
|
||
const applyPreset = useCallback(
|
||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||
const { tree: nextTree, dropped } = reshapeToPreset(
|
||
tree,
|
||
make,
|
||
defaultShellAsLeafProps(defaultShell),
|
||
);
|
||
|
||
if (dropped.length > 0) {
|
||
const ok = window.confirm(
|
||
`This preset has fewer slots than your current ${leafCount(tree)} panes. ${dropped.length} pane${dropped.length === 1 ? "" : "s"} will be closed (their shells will be killed). Continue?`,
|
||
);
|
||
if (!ok) return;
|
||
|
||
for (const id of dropped) {
|
||
const paneId = paneIdByLeafRef.current.get(id);
|
||
if (paneId != null) {
|
||
void killPane(paneId).catch((e) =>
|
||
console.warn("killPane failed:", e),
|
||
);
|
||
paneIdByLeafRef.current.delete(id);
|
||
}
|
||
}
|
||
if (activeLeafId && dropped.includes(activeLeafId)) {
|
||
setActiveLeafId(null);
|
||
}
|
||
}
|
||
|
||
setTree(nextTree);
|
||
},
|
||
[tree, defaultShell, activeLeafId],
|
||
);
|
||
|
||
const paletteLeaves = useMemo<LeafNode[]>(
|
||
() => (paletteOpen ? Array.from(walkLeaves(tree)) : []),
|
||
[paletteOpen, 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));
|
||
}, []);
|
||
|
||
// ---- global broadcast state (derived from tree) -------------------------
|
||
const broadcastStats = useMemo(() => {
|
||
let on = 0;
|
||
let total = 0;
|
||
for (const leaf of walkLeaves(tree)) {
|
||
total++;
|
||
if (leaf.broadcast) on++;
|
||
}
|
||
return { on, total };
|
||
}, [tree]);
|
||
|
||
const toggleBroadcastAll = useCallback(() => {
|
||
// If any pane is broadcasting, turn them all off. Otherwise turn them all on.
|
||
setTree((t) => setAllBroadcast(t, broadcastStats.on === 0));
|
||
}, [broadcastStats.on]);
|
||
|
||
const broadcastBtnLabel =
|
||
broadcastStats.on === 0
|
||
? "📡 all off"
|
||
: broadcastStats.on === broadcastStats.total
|
||
? "📡 all on"
|
||
: `📡 ${broadcastStats.on}/${broadcastStats.total}`;
|
||
|
||
const onPalettePick = useCallback((leafId: string) => {
|
||
setActiveLeafId(leafId);
|
||
setPaletteOpen(false);
|
||
}, []);
|
||
|
||
// Label shown on the default-shell chip — current selection at a glance.
|
||
const isDefaultDistro = (d: string) =>
|
||
defaultShell.shellKind === "wsl" && defaultShell.distro === d;
|
||
const isDefaultPowershell = defaultShell.shellKind === "powershell";
|
||
const defaultShellLabel =
|
||
defaultShell.shellKind === "powershell"
|
||
? "PowerShell"
|
||
: (defaultShell.distro ?? "(none)");
|
||
|
||
return (
|
||
<div className="app">
|
||
<header className="titlebar">
|
||
<span className="label">tiletopia</span>
|
||
|
||
<span className="distro-wrap">
|
||
<button
|
||
className="titlebar-chip"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setDefaultShellMenuOpen((v) => !v);
|
||
}}
|
||
title="Shell that '+' will spawn (also the boot default)"
|
||
>
|
||
{defaultShellLabel} ▾
|
||
</button>
|
||
{defaultShellMenuOpen && (
|
||
<div
|
||
className="distro-menu shell-menu"
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{distros.length > 0 ? (
|
||
<>
|
||
<div className="shell-menu-header">WSL</div>
|
||
{distros.map((d) => (
|
||
<button
|
||
key={d}
|
||
className={`distro-menu-item${isDefaultDistro(d) ? " active" : ""}`}
|
||
onClick={() => {
|
||
setDefaultShell({ shellKind: "wsl", distro: d });
|
||
setDefaultShellMenuOpen(false);
|
||
}}
|
||
>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="shell-menu-header">WSL</div>
|
||
<div className="shell-menu-empty">(no distros)</div>
|
||
</>
|
||
)}
|
||
<div className="shell-menu-header">Windows</div>
|
||
<button
|
||
className={`distro-menu-item${isDefaultPowershell ? " active" : ""}`}
|
||
onClick={() => {
|
||
setDefaultShell({ shellKind: "powershell" });
|
||
setDefaultShellMenuOpen(false);
|
||
}}
|
||
>
|
||
PowerShell
|
||
</button>
|
||
</div>
|
||
)}
|
||
</span>
|
||
|
||
<button
|
||
className="titlebar-chip add-pane"
|
||
onClick={addPane}
|
||
title={`Spawn a new ${defaultShellLabel} pane (splits the active pane)`}
|
||
aria-label="Add pane"
|
||
>
|
||
+
|
||
</button>
|
||
|
||
<button
|
||
className="titlebar-chip"
|
||
onClick={openHostManager}
|
||
title="Add, edit, or remove saved SSH hosts"
|
||
aria-label="Manage SSH hosts"
|
||
>
|
||
🔑
|
||
</button>
|
||
|
||
<span className="distro-wrap">
|
||
<button
|
||
className="titlebar-chip"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setLayoutMenuOpen((v) => !v);
|
||
}}
|
||
title="Apply a preset layout (replaces current panes)"
|
||
>
|
||
layout ▾
|
||
</button>
|
||
{layoutMenuOpen && (
|
||
<div
|
||
className="distro-menu shell-menu"
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
className="distro-menu-item"
|
||
onClick={() => {
|
||
applyPreset(presetSingle);
|
||
setLayoutMenuOpen(false);
|
||
}}
|
||
>
|
||
Single pane
|
||
</button>
|
||
<button
|
||
className="distro-menu-item"
|
||
onClick={() => {
|
||
applyPreset(presetTwoColumns);
|
||
setLayoutMenuOpen(false);
|
||
}}
|
||
>
|
||
Two columns
|
||
</button>
|
||
<button
|
||
className="distro-menu-item"
|
||
onClick={() => {
|
||
applyPreset(presetThreeColumns);
|
||
setLayoutMenuOpen(false);
|
||
}}
|
||
>
|
||
Three columns
|
||
</button>
|
||
<button
|
||
className="distro-menu-item"
|
||
onClick={() => {
|
||
applyPreset(presetTwoRows);
|
||
setLayoutMenuOpen(false);
|
||
}}
|
||
>
|
||
Two rows
|
||
</button>
|
||
<button
|
||
className="distro-menu-item"
|
||
onClick={() => {
|
||
applyPreset(presetTwoByTwo);
|
||
setLayoutMenuOpen(false);
|
||
}}
|
||
>
|
||
2 × 2 grid
|
||
</button>
|
||
</div>
|
||
)}
|
||
</span>
|
||
|
||
<button
|
||
className={`palette-btn bcast-all${broadcastStats.on > 0 ? " on" : ""}${broadcastStats.on > 0 && broadcastStats.on < broadcastStats.total ? " partial" : ""}`}
|
||
onClick={toggleBroadcastAll}
|
||
title={
|
||
broadcastStats.on === 0
|
||
? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
|
||
: broadcastStats.on === broadcastStats.total
|
||
? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
|
||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
|
||
}
|
||
>
|
||
{broadcastBtnLabel}
|
||
</button>
|
||
|
||
<button
|
||
className="palette-btn"
|
||
onClick={() => setPaletteOpen(true)}
|
||
title="Jump to pane (Ctrl+K)"
|
||
>
|
||
⌘K
|
||
</button>
|
||
<button
|
||
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
||
onClick={() => setMcpPanelOpen(true)}
|
||
title={
|
||
mcpStatus.running
|
||
? `MCP server running (${allowedPaneCount} of ${leafCount(tree)} panes visible) — click for details`
|
||
: "MCP server is OFF — click to configure / start"
|
||
}
|
||
aria-label="MCP server"
|
||
aria-pressed={mcpStatus.running ? "true" : "false"}
|
||
>
|
||
🤖
|
||
</button>
|
||
<button
|
||
className="palette-btn usage-btn"
|
||
onClick={() => setUsagePanelOpen(true)}
|
||
title="claude token usage & estimated cost (Ctrl+Shift+U)"
|
||
aria-label="Usage"
|
||
>
|
||
💰{usageSessions.length > 0 ? ` ${formatUsd(totalCost(usageSessions))}` : ""}
|
||
</button>
|
||
<button
|
||
className="palette-btn"
|
||
onClick={() => setHelpOpen(true)}
|
||
title="Show keyboard shortcuts and tips (F1)"
|
||
aria-label="Help"
|
||
>
|
||
?
|
||
</button>
|
||
|
||
<span className="layout-info">
|
||
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
||
{idleLeafIds.size > 0 && (
|
||
<span className="idle-info" title="Panes that haven't produced output recently">
|
||
{" · "}
|
||
{idleLeafIds.size} idle
|
||
</span>
|
||
)}
|
||
</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}>
|
||
{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>
|
||
|
||
<Notifications notifications={notifications} onDismiss={dismissNotification} />
|
||
|
||
{paletteOpen && (
|
||
<Palette
|
||
leaves={paletteLeaves}
|
||
onPick={onPalettePick}
|
||
onClose={() => setPaletteOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{hostManagerOpen && (
|
||
<HostManager
|
||
hosts={hosts}
|
||
onSave={saveHosts}
|
||
onSavePassword={savePassword}
|
||
onClearPassword={clearPassword}
|
||
onConnect={(hostId) => {
|
||
connectToHost(hostId);
|
||
closeHostManager();
|
||
}}
|
||
onClose={closeHostManager}
|
||
/>
|
||
)}
|
||
|
||
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
|
||
|
||
{mcpPanelOpen && (
|
||
<McpPanel
|
||
status={mcpStatus}
|
||
onStart={startMcp}
|
||
onStop={stopMcp}
|
||
onRegenerateToken={regenerateMcpToken}
|
||
onClose={() => setMcpPanelOpen(false)}
|
||
allowedPaneCount={allowedPaneCount}
|
||
totalPaneCount={leafCount(tree)}
|
||
auditEntries={auditEntries}
|
||
onClearAudit={clearAudit}
|
||
/>
|
||
)}
|
||
|
||
{usagePanelOpen && (
|
||
<UsagePanel
|
||
sessions={usageSessions}
|
||
loading={usageLoading}
|
||
onRefresh={refreshUsage}
|
||
onClose={() => setUsagePanelOpen(false)}
|
||
openPanes={openPanes}
|
||
/>
|
||
)}
|
||
|
||
{confirmQueue.length > 0 && (
|
||
<McpConfirm
|
||
spec={confirmQueue[0]}
|
||
onAccept={() => dismissConfirm(true)}
|
||
onReject={() => dismissConfirm(false)}
|
||
onAlwaysAllow={async () => {
|
||
await alwaysAllowTool(confirmQueue[0].tool);
|
||
dismissConfirm(true);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** When splitting a leaf, the new sibling defaults to whatever the parent
|
||
* is running — so "split right" inside an Ubuntu pane gives you another
|
||
* Ubuntu pane, same SSH host gives you another connection to that host,
|
||
* etc. If no parent (shouldn't happen with current callers), fall back to
|
||
* the app-level default. */
|
||
function inheritShellFromParent(
|
||
parent: LeafNode | null,
|
||
fallback: DefaultShell,
|
||
): Partial<LeafNode> {
|
||
if (!parent) return defaultShellAsLeafProps(fallback);
|
||
if (parent.shellKind === "wsl") {
|
||
return {
|
||
shellKind: "wsl",
|
||
distro: parent.distro ?? (fallback.shellKind === "wsl" ? fallback.distro : undefined),
|
||
cwd: parent.cwd,
|
||
};
|
||
}
|
||
if (parent.shellKind === "powershell") {
|
||
return { shellKind: "powershell" };
|
||
}
|
||
return { shellKind: "ssh", sshHostId: parent.sshHostId };
|
||
}
|
||
|
||
/** For previously-saved workspaces written before shellKind existed: any
|
||
* WSL leaf without an explicit distro inherits the resolved default. */
|
||
function backfillWslDistro(node: TreeNode, fallback: string) {
|
||
if (node.kind === "leaf") {
|
||
if (node.shellKind === "wsl" && !node.distro) node.distro = fallback;
|
||
} else {
|
||
backfillWslDistro(node.a, fallback);
|
||
backfillWslDistro(node.b, fallback);
|
||
}
|
||
}
|