[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo } from 'react';
import {
fromEditorTabId,
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type {
Host,
TerminalSession,
TerminalTheme,
Workspace,
} from '../../types';
import type { LogView } from '../state/logViewState';
import type { EditorTabChrome } from '../state/editorTabStore';
export interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
t: (key: string) => string;
}
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: window title and the SFTP-tab guard.
* Extracted out of <App> so that switching top tabs only
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Accent comes from appearanceChromeStore so color-picker drag does not
* rebuild AppShell chrome props.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}), [
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
]);
updateActiveChromeThemeDeps(chromeThemeDeps);
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
}), [chromeThemeDeps, activeTabId]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
// Title is already memoized by activeTabId; skip redundant IPC when the
// string did not change (e.g. two tabs sharing the same host label).
let cancelled = false;
const bridge = netcattyBridge.get();
if (!bridge?.setWindowTitle) return;
// Defer slightly so the title write does not compete with tab-switch paint.
const timer = window.setTimeout(() => {
if (!cancelled) void bridge.setWindowTitle?.(activeWindowTitle);
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeWindowTitle]);
return null;
}

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
test("follow-app terminal theme selection updates the matching UI theme via ThemeRuntime", () => {
assert.match(runtimeSource, /getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
assert.match(runtimeSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(terminalHostSource, /useThemeRuntime\(/);
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
assert.match(terminalHostSource, /clearThemeIntent,/);
assert.match(terminalHostSource, /settleManualThemeIntent,/);
assert.match(terminalHostSource, /pickTerminalTheme,/);
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.doesNotMatch(
terminalHostSource,
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
);
// Terminal domain must not thrash on whole settings bag identity.
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
assert.doesNotMatch(
terminalHostSource,
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
);
// Hotkey path must not depend on whole settings/sessions for callback identity.
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
assert.match(settingsSource, /appearanceTransitionModeRef\.current = 'instant'/);
assert.match(appViewSource, /data-terminal-appearance-root/);
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection persists via TerminalHost", () => {
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleTrayTogglePortForwardImpl } from './AppHandlers';
const rule = { id: 'rule-1', hostId: 'host-1' };
const host = { id: 'host-1' };
function createContext(options: { requestedStart: boolean; hasRuntimeTunnel: boolean }) {
const calls = { start: 0, stop: 0 };
const context = {
hasRuntimeTunnel: () => options.hasRuntimeTunnel,
hosts: [host],
identities: [],
keys: [],
knownHosts: [],
portForwardingRules: [rule],
resolveEffectiveHost: (value: unknown) => value,
startTunnel: () => {
calls.start += 1;
return Promise.resolve();
},
stopTunnel: () => {
calls.stop += 1;
return Promise.resolve({ success: true });
},
t: (key: string) => key,
terminalSettings: {},
toast: { error: () => undefined },
};
handleTrayTogglePortForwardImpl(() => context, rule.id, options.requestedStart);
return calls;
}
test('tray ignores a stale start request when the tunnel is already running', () => {
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: true });
assert.deepEqual(calls, { start: 0, stop: 0 });
});
test('tray starts an inactive rule when no runtime tunnel exists', () => {
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: false });
assert.deepEqual(calls, { start: 1, stop: 0 });
});
test('tray stop requests remain idempotent', () => {
const calls = createContext({ requestedStart: false, hasRuntimeTunnel: false });
assert.deepEqual(calls, { start: 0, stop: 1 });
});

View File

@@ -0,0 +1,237 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
import { createCopiedTerminalSessionClone } from "../state/terminalConnectionReuse";
import type { TerminalSession } from "../../domain/models";
type CloneOpts = { localShellType?: string; inheritedCwd?: string; reuseConnection?: boolean };
type Calls = {
copy?: { id: string; opts: CloneOpts };
split?: { id: string; dir: string; opts: CloneOpts };
probed: boolean;
};
function ctxFactory(overrides: Record<string, unknown>) {
const calls: Calls = { probed: false };
const base = {
classifyLocalShellType: () => "posix",
discoveredShells: [],
resolveShellSetting: () => ({ command: "/bin/bash", args: [] }),
terminalSettings: { localShell: "bash" },
sessions: [{ id: "src", protocol: "ssh", status: "connected", lastCwd: "/var/log" }],
// hostById is a Map of saved hosts in the real App — the impl must use
// .get(), not call it as a function.
hostById: new Map<string, { id: string; distro?: string; deviceType?: string }>(),
terminalHosts: [] as Array<{ id: string; distro?: string; deviceType?: string }>,
getSessionRestoreCwd: () => undefined,
netcattyBridge: {
get: () => ({
getSessionPwd: async () => { calls.probed = true; return { success: true, cwd: "/live/probed" }; },
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: "OpenSSH_9.6" }),
}),
},
copySession: (id: string, opts: CloneOpts) => { calls.copy = { id, opts }; },
splitSession: (id: string, dir: string, opts: CloneOpts) => { calls.split = { id, dir, opts }; },
...overrides,
};
return { getCtx: () => base, calls };
}
test("copySessionWithCurrentShell does not throw when hostById is a Map and probes live cwd", async () => {
const { getCtx, calls } = ctxFactory({});
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.opts.inheritedCwd, "/live/probed");
assert.equal(calls.probed, true);
});
test("splitSessionWithCurrentShell passes inheritedCwd", async () => {
const { getCtx, calls } = ctxFactory({});
await splitSessionWithCurrentShellImpl(getCtx, "src", "horizontal");
assert.equal(calls.split?.opts.inheritedCwd, "/live/probed");
});
test("live tracked cwd is preferred over the probe", async () => {
const { getCtx, calls } = ctxFactory({ getSessionRestoreCwd: () => "/live/tracked" });
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.opts.inheritedCwd, "/live/tracked");
assert.equal(calls.probed, false, "must not probe when live cwd is known");
});
for (const protocol of ["ssh", undefined] as const) {
for (const liveCwd of ["/srv/old-target", undefined]) {
test(`duplicate SSH session does not capture or inject the old target directory (${protocol}, ${liveCwd})`, async () => {
const source: TerminalSession = {
id: "src", hostId: "bastion", hostLabel: "Bastion", hostname: "bastion.test",
username: "alice", protocol, status: "connected", lastCwd: "/saved/old-target",
};
let cwdReads = 0;
let bridgeReads = 0;
const { getCtx, calls } = ctxFactory({
sessions: [source],
getSessionRestoreCwd: () => { cwdReads += 1; return liveCwd; },
netcattyBridge: { get: () => { bridgeReads += 1; return {}; } },
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.id, "src");
assert.equal(calls.copy?.opts.reuseConnection, false);
assert.equal(calls.copy?.opts.inheritedCwd, undefined);
assert.equal(cwdReads, 0, "fresh remote login must not read the previous target's directory");
assert.equal(bridgeReads, 0, "fresh remote login must not probe the previous target");
const clone = createCopiedTerminalSessionClone(source, {
id: "duplicate",
inheritedCwd: calls.copy?.opts.inheritedCwd,
reuseConnection: calls.copy?.opts.reuseConnection,
});
assert.equal(clone.requireFreshConnection, true);
assert.equal(clone.pendingInitialCwd, undefined);
});
}
}
test("duplicate local session retains the current working directory", async () => {
const { getCtx, calls } = ctxFactory({
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/home/alice" }],
getSessionRestoreCwd: () => "/home/alice/project",
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.opts.inheritedCwd, "/home/alice/project");
assert.equal(calls.probed, false);
});
test("network device (by deviceType) is never probed", async () => {
const { getCtx, calls } = ctxFactory({
hostById: new Map([["h1", { id: "h1", deviceType: "network" }]]),
sessions: [{ id: "src", hostId: "h1", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
});
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.probed, false, "must not open a probe channel on a network device");
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
});
test("local sessions do not query remote SSH metadata", async () => {
let remoteInfoCalls = 0;
const { getCtx } = ctxFactory({
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/tmp" }],
netcattyBridge: {
get: () => ({
getSessionPwd: async () => ({ success: false }),
getSessionRemoteInfo: async () => { remoteInfoCalls += 1; return { success: true }; },
}),
},
});
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(remoteInfoCalls, 0);
});
test("network device detected via distro (ignores cosmetic override) is never probed", async () => {
const { getCtx, calls } = ctxFactory({
hostById: new Map([["h1", { id: "h1", distro: "huawei" }]]),
sessions: [{ id: "src", hostId: "h1", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
});
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.probed, false);
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
});
test("ephemeral network host (only in terminalHosts) is never probed", async () => {
const { getCtx, calls } = ctxFactory({
hostById: new Map(),
terminalHosts: [{ id: "eph", deviceType: "network" }],
sessions: [{ id: "src", hostId: "eph", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
});
await copySessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.probed, false);
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
});
type WorkspaceNode =
| { id: string; type: "pane"; sessionId: string }
| { id: string; type: "split"; direction: string; children: WorkspaceNode[] };
type CopyWorkspaceOpts = { localShellType?: string; perPaneCwd?: Record<string, string | undefined> };
test("copyWorkspaceWithCurrentShell captures per-pane cwd and copies the workspace", async () => {
const calls: { copy?: { id: string; opts: CopyWorkspaceOpts } } = {};
const sessions = [
{ id: "p1", protocol: "local", localStartDir: "/home/a" },
{ id: "p2", protocol: "local", localStartDir: "/home/b" },
];
const workspaces = [{
id: "ws-1",
root: {
id: "sp", type: "split", direction: "vertical",
children: [
{ id: "n1", type: "pane", sessionId: "p1" },
{ id: "n2", type: "pane", sessionId: "p2" },
],
} as WorkspaceNode,
}];
const collectIds = (node: WorkspaceNode): string[] =>
node.type === "pane" ? [node.sessionId] : node.children.flatMap(collectIds);
const getCtx = () => ({
classifyLocalShellType: () => "bash",
collectSessionIds: collectIds,
copyWorkspace: (id: string, opts: CopyWorkspaceOpts) => { calls.copy = { id, opts }; },
discoveredShells: [],
getSessionRestoreCwd: () => undefined,
hostById: new Map(),
terminalHosts: [],
netcattyBridge: { get: () => ({}) },
resolveShellSetting: () => ({ command: "bash" }),
sessions,
terminalSettings: { localShell: "bash" },
workspaces,
});
await copyWorkspaceWithCurrentShellImpl(getCtx, "ws-1");
assert.equal(calls.copy?.id, "ws-1");
assert.deepEqual(calls.copy?.opts.perPaneCwd, { p1: "/home/a", p2: "/home/b" });
assert.equal(calls.copy?.opts.localShellType, "bash");
});
test("copyWorkspaceWithCurrentShell no-ops when the workspace is gone", async () => {
let called = false;
const getCtx = () => ({
classifyLocalShellType: () => "bash",
collectSessionIds: () => [],
copyWorkspace: () => { called = true; },
discoveredShells: [],
getSessionRestoreCwd: () => undefined,
hostById: new Map(),
terminalHosts: [],
netcattyBridge: { get: () => ({}) },
resolveShellSetting: () => ({ command: "bash" }),
sessions: [],
terminalSettings: { localShell: "bash" },
workspaces: [],
});
await copyWorkspaceWithCurrentShellImpl(getCtx, "missing");
assert.equal(called, false);
});
test("copyWorkspaceWithCurrentShell no-ops when the workspace closes during cwd capture", async () => {
let called = false;
let workspaces: Array<{ id: string; root: WorkspaceNode }> = [{
id: "ws-1",
root: { id: "p", type: "pane", sessionId: "local" },
}];
const getCtx = () => ({
classifyLocalShellType: () => "bash",
collectSessionIds: () => ["local"],
copyWorkspace: () => { called = true; },
discoveredShells: [],
getSessionRestoreCwd: () => undefined,
hostById: new Map(),
terminalHosts: [],
netcattyBridge: { get: () => ({}) },
resolveShellSetting: () => ({ command: "bash" }),
sessions: [{ id: "local", protocol: "local", status: "connected", localStartDir: "/tmp" }],
terminalSettings: { localShell: "bash" },
workspaces,
});
const pending = copyWorkspaceWithCurrentShellImpl(getCtx, "ws-1");
workspaces = [];
await pending;
assert.equal(called, false);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import type { GroupConfig, Host } from '../../types';
import {
collectWorkSurfaceHostGroups,
collectWorkSurfaceHostTags,
getAppHostEditorLayerStyle,
resolveWorkSurfaceHostEditorKind,
} from './AppHostEditorLayer';
const host = (overrides: Partial<Host> = {}): Host => ({
id: 'host-1',
label: 'web',
hostname: '10.0.0.1',
username: 'root',
port: 22,
protocol: 'ssh',
tags: [],
os: 'linux',
createdAt: 1,
...overrides,
});
test('serial targets use the serial editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({
mode: 'edit',
openedHost: host({ protocol: 'serial' }),
requestId: 1,
}),
'serial',
);
});
test('new and ssh targets use the standard editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
'standard',
);
assert.equal(
resolveWorkSurfaceHostEditorKind({ mode: 'edit', openedHost: host(), requestId: 2 }),
'standard',
);
});
test('editor collections include configured, saved, custom, and ancestor groups', () => {
assert.deepEqual(
collectWorkSurfaceHostGroups(
[host({ group: 'prod/web' })],
['manual'],
[{ path: 'prod' } as GroupConfig],
),
['manual', 'prod', 'prod/web'],
);
});
test('editor tags are unique and sorted', () => {
assert.deepEqual(
collectWorkSurfaceHostTags([
host({ tags: ['prod', 'blue'] }),
host({ id: 'host-2', tags: ['blue'] }),
]),
['blue', 'prod'],
);
});
test('editor overlay leaves the work surface interactive outside the panel', () => {
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
assert.match(source, /\[&>\*\]:pointer-events-auto/);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
});
test('editor host panels share vault resize width persistence', () => {
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
assert.match(source, /resizable:\s*true/);
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
});
test('editor stays mounted while another app surface is active', () => {
assert.deepEqual(getAppHostEditorLayerStyle(false), {
display: 'none',
pointerEvents: 'none',
});
assert.deepEqual(getAppHostEditorLayerStyle(true), {
display: undefined,
pointerEvents: undefined,
});
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
assert.doesNotMatch(source, /!surfaceVisible\) return null/);
assert.match(source, /style=\{getAppHostEditorLayerStyle\(surfaceVisible\)\}/);
assert.match(source, /ref=\{setPortalContainer\}/);
assert.match(source, /<PortalContainerProvider container=\{portalContainer\}>/);
});
test('AppView composes host-tree actions with the work-surface editor', () => {
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
assert.match(source, /useWorkSurfaceHostEditor/);
assert.match(source, /<AppHostEditorLayer/);
assert.match(source, /onNewHost=\{workSurfaceHostEditor\.openNew\}/);
assert.match(source, /onEditHost=\{workSurfaceHostEditor\.openEdit\}/);
assert.match(source, /terminal\.layer\.hostTree\.hostSavedNextConnection/);
});

View File

@@ -0,0 +1,207 @@
import React, { useMemo, useState } from 'react';
import type { WorkSurfaceHostEditorTarget } from '../state/useWorkSurfaceHostEditor';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useI18n } from '../i18n/I18nProvider';
import HostDetailsPanel from '../../components/HostDetailsPanel';
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
import { PortalContainerProvider } from '../../components/ui/portal-container';
import { resolveGroupDefaults } from '../../domain/groupConfig';
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from '@/infrastructure/config/storageKeys';
import type {
GroupConfig,
Host,
Identity,
ManagedSource,
ProxyProfile,
Snippet,
SSHKey,
TerminalSession,
Workspace,
} from '../../types';
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
export function resolveWorkSurfaceHostEditorKind(
target: WorkSurfaceHostEditorTarget,
): WorkSurfaceHostEditorKind {
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
? 'serial'
: 'standard';
}
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
const segments = path?.split('/').filter(Boolean) ?? [];
for (let index = 1; index <= segments.length; index += 1) {
groups.add(segments.slice(0, index).join('/'));
}
}
export function collectWorkSurfaceHostGroups(
hosts: Host[],
customGroups: string[],
groupConfigs: GroupConfig[],
): string[] {
const groups = new Set<string>();
for (const path of customGroups) addGroupAndAncestors(groups, path);
for (const config of groupConfigs) addGroupAndAncestors(groups, config.path);
for (const host of hosts) addGroupAndAncestors(groups, host.group);
return Array.from(groups).sort((left, right) => left.localeCompare(right));
}
export function collectWorkSurfaceHostTags(hosts: Host[]): string[] {
const tags = new Set<string>();
for (const host of hosts) {
for (const tag of host.tags ?? []) tags.add(tag);
}
return Array.from(tags).sort((left, right) => left.localeCompare(right));
}
export function getAppHostEditorLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
display: surfaceVisible ? undefined : 'none',
pointerEvents: surfaceVisible ? undefined : 'none',
};
}
interface AppHostEditorLayerProps {
/** When omitted, surface visibility is derived from activeTabId in this leaf. */
surfaceVisible?: boolean;
target: WorkSurfaceHostEditorTarget | null;
editorKey: string | null;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
managedSources: ManagedSource[];
snippets: Snippet[];
terminalThemeId: string;
terminalFontSize: number;
/** Required when surfaceVisible is not passed (leaf active-tab subscription). */
sessions?: TerminalSession[];
workspaces?: Workspace[];
logViews?: readonly LogView[];
orderedTabs?: readonly string[];
editorTabs?: readonly EditorTabChrome[];
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup: (groupPath: string) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
}
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
surfaceVisible: surfaceVisibleProp,
target,
editorKey,
hosts,
customGroups,
groupConfigs,
keys,
identities,
proxyProfiles,
managedSources,
snippets,
terminalThemeId,
terminalFontSize,
sessions = [],
workspaces = [],
logViews = [],
orderedTabs = [],
onSave,
onCancel,
onCreateGroup,
onImportOrReuseKey,
onUpdateSnippets,
onUpdateHosts,
}) => {
const { t } = useI18n();
const derivedSurfaceVisible = useWorkSurfaceVisible({
enabled: true,
sessions,
workspaces,
logViews,
orderedTabs,
});
// Prefer explicit prop only when provided, so existing tests keep control.
const surfaceVisible = surfaceVisibleProp ?? derivedSurfaceVisible;
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
const groups = useMemo(
() => collectWorkSurfaceHostGroups(hosts, customGroups, groupConfigs),
[customGroups, groupConfigs, hosts],
);
const allTags = useMemo(() => collectWorkSurfaceHostTags(hosts), [hosts]);
const groupPath = target?.mode === 'edit'
? target.openedHost.group
: target?.defaultGroup;
const groupDefaults = useMemo(
() => (groupPath ? resolveGroupDefaults(groupPath, groupConfigs) : undefined),
[groupConfigs, groupPath],
);
// Share width persistence with Vault host details so both entry points feel consistent.
const hostPanelResizeProps = {
resizable: true as const,
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t('vault.panel.resizeWidth'),
};
if (!target || !editorKey) return null;
return (
<div
ref={setPortalContainer}
className="pointer-events-none absolute inset-0 z-40 [&>*]:pointer-events-auto"
data-section="app-host-editor-layer"
style={getAppHostEditorLayerStyle(surfaceVisible)}
>
<PortalContainerProvider container={portalContainer}>
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
<SerialHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
allTags={allTags}
groups={groups}
groupDefaults={groupDefaults}
onSave={onSave}
onCancel={onCancel}
layout="overlay"
className="pointer-events-auto"
{...hostPanelResizeProps}
/>
) : (
<HostDetailsPanel
key={editorKey}
initialData={target.mode === 'edit' ? target.openedHost : null}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={groups}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
defaultGroup={target.mode === 'new' ? target.defaultGroup : undefined}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
groupDefaults={groupDefaults}
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={onSave}
onCancel={onCancel}
onCreateGroup={onCreateGroup}
layout="overlay"
className="pointer-events-auto"
{...hostPanelResizeProps}
/>
)}
</PortalContainerProvider>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import type { TerminalSession, Workspace } from '../../types';
import { useActiveTabId } from '../state/activeTabStore';
import { isHostTreeWorkTabSurface } from './workTabSurface';
/**
* Subscribes to activeTabId and exposes work-surface visibility without
* forcing the AppView shell to re-render on every top-tab switch.
*/
export function useWorkSurfaceVisible({
enabled,
sessions,
workspaces,
logViews,
orderedTabs,
}: {
enabled: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
}): boolean {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(
() => new Set(sessions.map((session) => session.id)),
[sessions],
);
const workspaceIds = useMemo(
() => new Set(workspaces.map((workspace) => workspace.id)),
[workspaces],
);
const logViewIds = useMemo(
() => new Set(logViews.map((logView) => logView.id)),
[logViews],
);
return useMemo(() => isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), [activeTabId, enabled, logViewIds, orderedTabs, sessionIds, workspaceIds]);
}
/** Tiny marker export so tests can pin the isolation helper module. */
export type WorkSurfaceEditorTabChrome = EditorTabChrome;

View File

@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
getAppHostTreeLayerStyle,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
test('shared host tree layer is visible above work tabs', () => {
assert.deepEqual(getAppHostTreeLayerStyle(true), {
visibility: 'visible',
pointerEvents: 'auto',
zIndex: 30,
});
});
test('shared host tree layer is hidden behind root pages', () => {
assert.deepEqual(getAppHostTreeLayerStyle(false), {
visibility: 'hidden',
pointerEvents: 'none',
zIndex: 0,
});
});
test('shared host tree does not force open when entering a work tab surface', () => {
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});
test('shared host tree theme follows active chrome resolution and manual chrome injection', () => {
assert.match(hostTreeLayerSource, /resolveActiveChromeTheme/);
assert.match(hostTreeLayerSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(hostTreeLayerSource, /resolveSessionAppearance/);
});
test('shared host tree forwards work-surface host management callbacks', () => {
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
});
test('shared host tree layer is memoized with a custom areEqual', () => {
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
});

View File

@@ -0,0 +1,183 @@
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from '../../domain/terminalAppearanceRuntime';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
onConnect: (host: Host) => void;
onNewHost?: (defaultGroup?: string) => void;
onEditHost?: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
visibility: surfaceVisible ? 'visible' : 'hidden',
pointerEvents: surfaceVisible ? 'auto' : 'none',
zIndex: surfaceVisible ? 30 : 0,
};
}
function appHostTreeLayerAreEqual(
prev: AppHostTreeLayerProps,
next: AppHostTreeLayerProps,
): boolean {
return prev.enabled === next.enabled
&& prev.hosts === next.hosts
&& prev.customGroups === next.customGroups
&& prev.groupConfigs === next.groupConfigs
&& prev.sessions === next.sessions
&& prev.workspaces === next.workspaces
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
// accentMode / customAccent intentionally omitted — read from
// appearanceChromeStore so accent drag does not rebuild the App shell.
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
&& prev.onConnect === next.onConnect
&& prev.onNewHost === next.onNewHost
&& prev.onEditHost === next.onEditHost
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
}
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
groupConfigs,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
currentTerminalTheme,
followAppTerminalTheme,
hostById,
themeById,
resolveSessionAppearance,
onConnect,
onNewHost,
onEditHost,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => (
resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}) ?? currentTerminalTheme
), [
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
]);
useManualTerminalChromeSurfaceInjection(
hostTreeTheme,
!followAppTerminalTheme && surfaceVisible,
);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
data-section="app-host-tree-layer"
style={getAppHostTreeLayerStyle(surfaceVisible)}
>
<TerminalHostTreeSidebar
enabled={enabled}
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onNewHost={onNewHost}
onEditHost={onEditHost}
onCreateLocalTerminal={onCreateLocalTerminal}
/>
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
/**
* Owns dialog / queue / ephemeral React state for the main window.
*
* The concrete state currently lives in `AppSideEffects` (same React tree)
* and is published into `appLocalUiStore` for Host islands. This provider is
* the composition slot the architecture requires so App itself never owns
* domain bags or mega-hook subscriptions.
*/
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
left: 220,
});
});
test('hidden log view remains hidden while preserving host sidebar offset', () => {
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
visibility: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: -1,
left: 220,
});
});
test('terminal layer renders only after terminal content is visible or mounted', () => {
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('inactive app surfaces suppress background color transitions', () => {
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
});
test('vault activation suppresses inherited text color transitions', () => {
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
});
test('terminal layer force-mounts immediately when a hidden MCP session exists', () => {
// A silent session never becomes activeTabId, so without this it would wait
// for the up-to-5s idle-callback fallback before TerminalPanesHost renders
// TerminalPane and starts the PTY — racing an immediate terminal_execute.
assert.match(appMountsSource, /hasHiddenSession = props\.sessions\.some\(\(session\) => session\.hiddenFromTabs\)/);
assert.match(appMountsSource, /useState\(isVisible \|\| hasHiddenSession\)/);
assert.match(appMountsSource, /if \(isVisible \|\| hasHiddenSession\) setShouldMount\(true\)/);
});

View File

@@ -0,0 +1,212 @@
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import type { LogView as LogViewType } from '../state/logViewState';
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
// Visibility container for VaultView - isolates isActive subscription
export const VaultViewContainer: React.FC<{
children: React.ReactNode;
appThemeStyle?: React.CSSProperties;
}> = ({ children, appThemeStyle }) => {
const isActive = useIsVaultActive();
const wasActiveRef = useRef(isActive);
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
const isActivating = isActive && !wasActiveRef.current;
const shouldSuppressTransition = isActivating || suppressActiveTransition;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
useLayoutEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isActive;
if (!isActive || wasActive) return;
setSuppressActiveTransition(true);
const view = window;
let firstFrame = 0;
let secondFrame = 0;
firstFrame = view.requestAnimationFrame(() => {
secondFrame = view.requestAnimationFrame(() => {
setSuppressActiveTransition(false);
});
});
return () => {
view.cancelAnimationFrame(firstFrame);
view.cancelAnimationFrame(secondFrame);
};
}, [isActive]);
return (
<div
className={cn("absolute inset-0", isActive ? "z-20" : "")}
data-inactive-app-surface={isActive ? undefined : "true"}
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
style={{ ...appThemeStyle, ...containerStyle }}
>
{children}
</div>
);
};
// LogView wrapper - manages visibility based on active tab
interface LogViewWrapperProps {
logView: LogViewType;
defaultTerminalTheme: TerminalTheme;
defaultFontSize: number;
onClose: () => void;
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
export function getLogViewWrapperStyle(
isVisible: boolean,
hostTreeLayoutWidth: number,
): React.CSSProperties {
const baseStyle = {
left: hostTreeLayoutWidth,
};
return isVisible
? baseStyle
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
data-inactive-app-surface={isVisible ? undefined : "true"}
style={containerStyle}
>
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
<Suspense fallback={<LogViewFallback />}>
<LazyLogView
log={logView.log}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
isVisible={isVisible}
onClose={onClose}
onUpdateLog={onUpdateLog}
/>
</Suspense>
</LazyLoadBoundary>
</div>
);
};
const LazyLogView = lazy(() => import('../../components/LogView'));
const LazySftpView = lazy(() =>
import('../../components/SftpView').then((m) => ({ default: m.SftpView })),
);
const LazyTerminalLayer = lazy(() =>
import('../../components/TerminalLayer').then((m) => ({ default: m.TerminalLayer })),
);
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
const LogViewFallback = () => (
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
);
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
export function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
): boolean {
return isVisible || shouldMount;
}
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
useEffect(() => {
if (isActive) setShouldMount(true);
}, [isActive]);
if (!shouldMount) return null;
return (
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
<LazySftpView {...props} />
</Suspense>
</LazyLoadBoundary>
);
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
const isVisible = isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}) || !!props.draggingSessionId;
// Silent MCP sessions never become the activeTabId, so `isVisible` alone
// would leave this whole layer (and its PTY-starting TerminalPane) unmounted
// for up to 5s (the idle-callback fallback below) after host_open returns —
// long enough for an immediate terminal_execute to race an unstarted session.
const hasHiddenSession = props.sessions.some((session) => session.hiddenFromTabs);
const [shouldMount, setShouldMount] = useState(isVisible || hasHiddenSession);
useEffect(() => {
if (isVisible || hasHiddenSession) setShouldMount(true);
}, [isVisible, hasHiddenSession]);
useEffect(() => {
if (shouldMount) return;
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
if (!shouldRender) return null;
return (
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
<LazyTerminalLayer {...props} />
</Suspense>
</LazyLoadBoundary>
);
};

View File

@@ -0,0 +1,40 @@
import React, { useMemo } from 'react';
import { PluginContributionHost } from '../../components/plugins/PluginContributionHost';
import type { TerminalSession, Workspace } from '../../types';
import { useActiveTabId } from '../state/activeTabStore';
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
/**
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
* Tab switches only re-render this small surface (and plugin lifecycle), not the shell.
*/
export function AppPluginKeybindingHost({
locale,
theme,
themeTokens,
sessions,
workspaces,
}: {
locale: string;
theme: string;
themeTokens?: Record<string, string>;
sessions: TerminalSession[];
workspaces: Workspace[];
}) {
const activeTabId = useActiveTabId();
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
}), [activeTabId, sessions, workspaces]);
return (
<PluginContributionHost
locale={locale}
theme={theme}
themeTokens={themeTokens}
keybindingContext={keybindingContext}
/>
);
}

View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
: [];
const hostSources = Object.fromEntries(
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
);
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
const APP_RUNTIME_HOOKS = [
'useAppVaultRuntime',
'useAppSessionRuntime',
'useAppSettingsRuntime',
] as const;
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(
appShellSource,
new RegExp(`${hook}\\s*\\(`),
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
);
}
});
test('AppShell composes the four Host islands', () => {
assert.match(appShellSource, /<VaultHost\b/);
assert.match(appShellSource, /<TerminalHost\b/);
assert.match(appShellSource, /<ChromeHost\b/);
assert.match(appShellSource, /<DialogsHost\b/);
});
test('AppShell renders the shell from store-backed bags only', () => {
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
assert.match(appShellSource, /appViewDomainsEqual/);
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
});
test('App renders through AppShell and owns no domain bags', () => {
assert.match(appSource, /<AppShell\b/);
assert.doesNotMatch(appSource, /<AppView\b/);
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
});
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
}
for (const hook of APP_RUNTIME_HOOKS) {
assert.doesNotMatch(
appSource,
new RegExp(`\\b${hook}\\s*\\(`),
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
);
}
assert.match(appSource, /<VaultPublisher>/);
assert.match(appSource, /<SessionPublisher\b/);
assert.match(appSource, /<SettingsPublisher\b/);
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
});
test('publishers hand their runtime to App through the app runtime bridge', () => {
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
assert.match(vaultPublisherSource, /notes: _notes/);
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
});
test('VaultHost builds from vault snapshot stores', () => {
const source = hostSources['VaultHost.tsx'];
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
assert.match(source, /useVaultSnapshot/);
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('TerminalHost builds from session snapshot + terminal settings store', () => {
const source = hostSources['TerminalHost.tsx'];
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
assert.match(source, /useSessionSnapshot/);
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
});
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
const source = hostSources['ChromeHost.tsx'];
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
assert.match(
sideEffectsSource,
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
);
});
test('AppSideEffects does not build domain bags for Hosts', () => {
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
// Flat glue only — no prepared domain bags on the handlers bridge.
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
});
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
const source = hostSources[name];
assert.ok(source, `application/app/hosts/${name} must exist`);
assert.doesNotMatch(
source,
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
`${name} must not read prepared *Domain / appShell* bags from handlers`,
);
assert.doesNotMatch(
source,
/\.\.\.\s*prepared/,
`${name} must not spread a prepared domain bag`,
);
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
}
});
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
const vaultHost = hostSources['VaultHost.tsx'];
const terminalHost = hostSources['TerminalHost.tsx'];
assert.ok(vaultHost && terminalHost);
// Notes / connection logs live in dedicated stores — VaultHost must not
// publish them into the vault domain bag that AppView consumes.
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
// Accent feeds useThemeRuntime for local injection only; the published
// terminal domain bag must not list accentMode/customAccent fields.
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
assert.notEqual(domainStart, -1);
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
assert.doesNotMatch(domain, /accentMode/);
assert.doesNotMatch(domain, /customAccent/);
assert.match(domain, /currentTerminalTheme,/);
assert.match(
terminalHost,
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
);
});
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});

View File

@@ -0,0 +1,68 @@
import { memo } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
import { AppActiveTabChrome } from './AppActiveTabChrome';
import { AppView } from './AppView';
import {
useAppShellProps,
type AppShellOverlays,
} from './appShellPropsStore';
import { ChromeHost } from './hosts/ChromeHost';
import { DialogsHost } from './hosts/DialogsHost';
import { TerminalHost } from './hosts/TerminalHost';
import { VaultHost } from './hosts/VaultHost';
export type { AppShellOverlays };
/**
* The rendered main window. Host islands subscribe to stores and publish
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
* re-renders when those bag identities change (see `appViewDomainsEqual` /
* chrome / overlays identity checks in the store).
*
* Default `memo` is enough: AppShell takes no props, so parent App updates
* do not force a re-render; store subscriptions via `useAppShellProps` still
* drive updates when Hosts publish new bag identities.
*
* `AppShell.architecture.test.ts` enforces Host composition and that the
* three mega hooks never reappear here.
*/
function AppShellView() {
const { t } = useI18n();
const { domains, chrome, overlays } = useAppShellProps();
return (
<>
<VaultHost />
<TerminalHost />
<ChromeHost />
<DialogsHost />
{domains && chrome && overlays ? (
<>
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
<ConfirmDialog
open={overlays.deleteHostConfirm !== null}
title={
overlays.deleteHostConfirm
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
: ''
}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) overlays.onCancelDeleteHost();
}}
onConfirm={overlays.onConfirmDeleteHost}
/>
<AppActiveTabChrome {...chrome} />
<AppView domains={domains} />
</>
) : null}
</>
);
}
export const AppShell = memo(AppShellView);
AppShell.displayName = 'AppShell';

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
const drainSource = source.slice(drainStart, drainEnd);
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
});

View File

@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
test('terminal Escape restoration runs before xterm can stop propagation', () => {
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
const effectSource = source.slice(effectStart, effectStart + 900);
assert.notEqual(effectStart, -1);
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
});

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
assert.match(source, /collectSnippetDeleteIds/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(
source,
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
);
assert.doesNotMatch(
source,
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
);
});
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
// already advanced useVaultState refs before React re-renders AppSideEffects.
// Deletion must go through the vault hook's atomic live-snapshot path.
assert.match(source, /deleteSelectedSnippets,/);
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const pluginHostSource = readFileSync(new URL("./AppPluginKeybindingHost.tsx", import.meta.url), "utf8");
const editorSurfaceSource = readFileSync(new URL("./AppHostEditorSurface.tsx", import.meta.url), "utf8");
test("AppView shell does not subscribe to useActiveTabId", () => {
// Top-tab switches must not rebuild the AppView shell. Leaves own the subscription.
assert.doesNotMatch(appViewSource, /useActiveTabId\s*\(/);
assert.doesNotMatch(appViewSource, /useActiveTabId/);
// Still uses activeTabStore for imperative tab close / neighbor activation.
assert.match(appViewSource, /activeTabStore/);
});
test("plugin keybindings and host-editor surface subscribe as leaves", () => {
assert.match(pluginHostSource, /useActiveTabId/);
assert.match(pluginHostSource, /resolveActivePluginKeybindingContext/);
assert.match(editorSurfaceSource, /useWorkSurfaceVisible/);
assert.match(editorSurfaceSource, /useActiveTabId/);
assert.match(appViewSource, /AppPluginKeybindingHost/);
});

1064
application/app/AppView.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
test('workspace append resolves group defaults before creating host sessions', () => {
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
assert.match(source, /groupConfigs,/);
assert.match(source, /proxyProfiles,/);
assert.match(
source,
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
);
assert.match(
source,
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
);
});

View File

@@ -0,0 +1,290 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toEditorTabId } from "../state/activeTabStore.ts";
import type { EditorTab } from "../state/editorTabStore.ts";
import type { LogView } from "../state/logViewState.ts";
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
id,
name: id,
type,
colors: {
background: type === "dark" ? "#111111" : "#eeeeee",
foreground: type === "dark" ? "#eeeeee" : "#111111",
cursor: "#22aaff",
},
});
const currentTheme = theme("current");
const hostTheme = theme("host-theme");
const logTheme = theme("log-theme", "light");
const baseInput = {
accentMode: "theme" as const,
currentTerminalTheme: currentTheme,
customAccent: "221.2 83.2% 53.3%",
editorTabs: [],
followAppTerminalTheme: false,
hostById: new Map<string, Host>(),
logViews: [],
sessionById: new Map<string, TerminalSession>(),
themeById: new Map([
[currentTheme.id, currentTheme],
[hostTheme.id, hostTheme],
[logTheme.id, logTheme],
]),
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("follow-app chrome applies custom accent onto the published base theme", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
accentMode: "custom",
customAccent: "0 100% 50%",
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
assert.notEqual(resolved, currentTheme);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "log-1",
logViews: [{
id: "log-1",
connectionLogId: "1",
log: { id: "1", themeId: logTheme.id },
} as unknown as LogView],
});
assert.equal(resolved?.id, logTheme.id);
});
test("root pages use the normal application theme", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "vault",
});
assert.equal(resolved, null);
});
test("follow-app workspace split view always uses the global terminal theme", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-1",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "session", sessionId: "session-1" },
{ type: "session", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
followAppTerminalTheme: true,
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("manual workspace split view uses the focused session theme when panes differ", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-2",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const focusedTheme = theme("focused-intent");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
resolveSessionAppearance: ({ host }) => (
host?.id === "host-b"
? { themeId: focusedTheme.id, theme: focusedTheme, source: "intent", appThemeUpdate: null }
: { themeId: hostTheme.id, theme: hostTheme, source: "host-override", appThemeUpdate: null }
),
});
assert.equal(resolved?.id, focusedTheme.id);
});
test("manual split workspace falls back to the first tree session theme when focus is stale", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "missing-session",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("manual mode prefers runtime session appearance over stale host theme ids", () => {
const intentTheme = theme("intent-theme");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "session-1",
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id, themeOverride: true } as unknown as Host],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-1" } as TerminalSession],
]),
resolveSessionAppearance: () => ({
themeId: intentTheme.id,
theme: intentTheme,
source: "intent",
appThemeUpdate: null,
}),
});
assert.equal(resolved?.id, intentTheme.id);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map(),
workspaceById: new Map(),
}),
false,
);
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
workspaceById: new Map(),
}),
true,
);
});

View File

@@ -0,0 +1,125 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from "../../domain/terminalAppearanceRuntime";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTabChrome } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
import { resolveWorkspaceTargetSessionFromMap } from "./workTabSurface";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTabChrome[];
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: ReadonlyMap<string, TerminalSession>;
themeById: ReadonlyMap<string, TerminalTheme>;
workspaceById: ReadonlyMap<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
activeTabId,
editorTabs,
logViews,
sessionById,
workspaceById,
}: Pick<
ResolveActiveChromeThemeInput,
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
>): boolean {
if (activeTabId === "vault" || activeTabId === "sftp") return true;
if (isEditorTabId(activeTabId)) {
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
}
if (logViews.some((item) => item.id === activeTabId)) return true;
if (workspaceById.has(activeTabId)) return true;
if (sessionById.has(activeTabId)) return true;
return false;
}
export function resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveHostScope = (hostId: string): TerminalAppearanceHostScope => {
const host = hostById.get(hostId) ?? null;
return { host, isEphemeral: !host || !hostById.has(host.id) };
};
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
if (resolveSessionAppearance) {
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
}
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
if (workspace.viewMode === "focus") {
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const workspaceSessions = collectSessionIds(workspace.root)
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (workspaceSessions.length === 0) return null;
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
if (allSame) return firstTheme;
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const session = sessionById.get(activeTabId);
return session ? resolveSessionTheme(session) : null;
}

View File

@@ -0,0 +1,64 @@
/**
* Stable accessor bridge for App-local handlers that Host islands need when
* assembling domain bags. `AppSideEffects` registers the live glue via
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
* props from App.
*
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
* tracks App glue, not a frozen public API.
*/
type Listener = () => void;
export type AppHandlers = Record<string, unknown>;
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of nextKeys) {
if (prev[key] !== next[key]) return false;
}
return true;
}
class AppHandlersBridge {
private handlers: AppHandlers | null = null;
private listeners = new Set<Listener>();
get = (): AppHandlers | null => this.handlers;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: AppHandlers | null): void {
if (this.handlers === next) return;
if (
this.handlers
&& next
&& handlersShallowEqual(this.handlers, next)
) {
return;
}
this.handlers = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new AppHandlersBridge();
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
export function getAppHandlers(): AppHandlers | null {
return bridge.get();
}
export function subscribeAppHandlers(listener: Listener): () => void {
return bridge.subscribe(listener);
}

View File

@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react';
import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
type Listener = () => void;
/**
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
* AppSideEffects never has to rebuild domain bags when a modal opens.
*
* `portForwardingRules` is published here as a thin derived slice: the PF
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
* must not receive a prepared terminal domain bag.
*/
export type AppLocalUiSnapshot = {
isQuickSwitcherOpen: boolean;
isCreateWorkspaceOpen: boolean;
addToWorkspaceDialog:
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
ephemeralHosts: readonly Host[];
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
emptyVaultConflict: unknown;
};
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
ephemeralHosts: Object.freeze([]) as readonly Host[],
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
emptyVaultConflict: null,
});
class AppLocalUiStore {
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
private listeners = new Set<Listener>();
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppLocalUiSnapshot): void {
const prev = this.snapshot;
if (
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
&& prev.ephemeralHosts === next.ephemeralHosts
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
&& prev.emptyVaultConflict === next.emptyVaultConflict
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
export const appLocalUiStore = new AppLocalUiStore();
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
appLocalUiStore.setSnapshot(snapshot);
}
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
return appLocalUiStore.getSnapshot();
}
export function subscribeAppLocalUi(listener: Listener): () => void {
return appLocalUiStore.subscribe(listener);
}
export function useAppLocalUiStore(): AppLocalUiSnapshot {
return useSyncExternalStore(
subscribeAppLocalUi,
getAppLocalUiSnapshot,
getAppLocalUiSnapshot,
);
}

View File

@@ -0,0 +1,120 @@
import { useSyncExternalStore } from 'react';
import type { KnownHost } from '../../types';
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
type Listener = () => void;
export type AppShellOverlays = {
onAddKnownHost: (knownHost: KnownHost) => void;
deleteHostConfirm: { hostId: string; name: string } | null;
onCancelDeleteHost: () => void;
onConfirmDeleteHost: () => void;
};
export type AppShellPropsSnapshot = {
domains: AppViewDomains | null;
chrome: AppActiveTabChromeProps | null;
overlays: AppShellOverlays | null;
};
const EMPTY: AppShellPropsSnapshot = Object.freeze({
domains: null,
chrome: null,
overlays: null,
});
/**
* Host islands publish domain / chrome / overlay bags here. `AppShell`
* subscribes via `useSyncExternalStore` and only re-renders when bag
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
*/
class AppShellPropsStore {
private snapshot: AppShellPropsSnapshot = EMPTY;
private listeners = new Set<Listener>();
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setDomains(domains: AppViewDomains): void {
const prev = this.snapshot;
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
return;
}
this.snapshot = { ...prev, domains };
for (const listener of this.listeners) listener();
}
setChrome(chrome: AppActiveTabChromeProps): void {
if (this.snapshot.chrome === chrome) return;
this.snapshot = { ...this.snapshot, chrome };
for (const listener of this.listeners) listener();
}
setOverlays(overlays: AppShellOverlays): void {
if (this.snapshot.overlays === overlays) return;
this.snapshot = { ...this.snapshot, overlays };
for (const listener of this.listeners) listener();
}
setDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
const prev = this.snapshot.domains;
if (prev && prev[key] === slice) return;
const domains = {
vault: prev?.vault ?? {},
terminal: prev?.terminal ?? {},
chrome: prev?.chrome ?? {},
dialogs: prev?.dialogs ?? {},
mounts: prev?.mounts ?? {},
[key]: slice,
} as AppViewDomains;
this.setDomains(domains);
}
}
export const appShellPropsStore = new AppShellPropsStore();
export function publishAppShellDomains(domains: AppViewDomains): void {
appShellPropsStore.setDomains(domains);
}
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
appShellPropsStore.setDomainSlice(key, slice);
}
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
appShellPropsStore.setChrome(chrome);
}
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
appShellPropsStore.setOverlays(overlays);
}
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
return appShellPropsStore.getSnapshot();
}
export function subscribeAppShellProps(listener: Listener): () => void {
return appShellPropsStore.subscribe(listener);
}
export function useAppShellProps(): AppShellPropsSnapshot {
return useSyncExternalStore(
subscribeAppShellProps,
getAppShellPropsSnapshot,
getAppShellPropsSnapshot,
);
}

View File

@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
appViewDomainsEqual,
mergeAppViewDomains,
type AppViewDomains,
} from './appViewDomains.ts';
test('appViewDomainsEqual is true only when all domain slice refs match', () => {
const vault = { hosts: [] };
const terminal = { sessions: [] };
const chrome = { theme: 'dark' };
const dialogs = { open: false };
const mounts = { TerminalLayerMount: null };
const a: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
const b: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
assert.equal(appViewDomainsEqual(a, b), true);
assert.equal(
appViewDomainsEqual(a, { ...b, terminal: { sessions: [{ id: 'x' }] } }),
false,
);
assert.equal(
appViewDomainsEqual(a, { ...b, vault: { hosts: [{ id: 'h' }] } }),
false,
);
});
test('mergeAppViewDomains flattens domains without shellHistory requirement', () => {
const merged = mergeAppViewDomains({
vault: { hosts: [1], notes: [] },
terminal: { sessions: [2] },
chrome: { orderedTabs: [] },
dialogs: { isQuickSwitcherOpen: false },
mounts: { VaultViewContainer: 'V' },
});
assert.deepEqual(merged.hosts, [1]);
assert.deepEqual(merged.sessions, [2]);
assert.equal(merged.VaultViewContainer, 'V');
assert.equal(Object.prototype.hasOwnProperty.call(merged, 'shellHistory'), false);
});
test('appViewDomainsEqual keeps AppView stable when only unrelated domain ref is same', () => {
const vault = { hosts: [] };
const terminal = { sessions: [] };
const chrome = { theme: 'dark' };
const dialogs = { open: false };
const mounts = { TerminalLayerMount: null };
const base: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
// Same domain refs → equal (title churn must not replace these refs).
assert.equal(appViewDomainsEqual(base, { vault, terminal, chrome, dialogs, mounts }), true);
// Terminal domain identity change (structural session change) → unequal.
assert.equal(
appViewDomainsEqual(base, { vault, terminal: { sessions: [] }, chrome, dialogs, mounts }),
false,
);
});

View File

@@ -0,0 +1,41 @@
/**
* Domain-scoped wiring for the main window shell.
* App re-renders may rebuild the parent, but AppView only re-renders when one
* of these domain slice identities changes.
*/
export type AppViewDomainBag = Record<string, unknown>;
export type AppViewDomains = {
/** Vault hosts/keys/notes/snippets (not shellHistory — that uses shellHistoryStore). */
vault: AppViewDomainBag;
/** Terminal sessions/workspaces/SFTP settings and terminal-layer handlers. */
terminal: AppViewDomainBag;
/** Top chrome: tabs, theme chrome, sync, host tree related. */
chrome: AppViewDomainBag;
/** Modals, queues, rename targets, quick switcher. */
dialogs: AppViewDomainBag;
/** Lazy mount components (stable module references). */
mounts: AppViewDomainBag;
};
export function mergeAppViewDomains(domains: AppViewDomains): AppViewDomainBag {
return {
...domains.vault,
...domains.terminal,
...domains.chrome,
...domains.dialogs,
...domains.mounts,
};
}
export function appViewDomainsEqual(
prev: AppViewDomains,
next: AppViewDomains,
): boolean {
return prev.vault === next.vault
&& prev.terminal === next.terminal
&& prev.chrome === next.chrome
&& prev.dialogs === next.dialogs
&& prev.mounts === next.mounts;
}

View File

@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../domain/models";
import {
canApplyDedicatedResumeProgress,
createDedicatedResumeChildUpdateBatcher,
createDedicatedResumeProgressBatcher,
DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE,
} from "./dedicatedResumeProgress";
test("deferred dedicated-resume progress cannot reopen a settled row", () => {
for (const status of [
"pausing",
"paused",
"completed",
"failed",
"cancelled",
"attention",
"interrupted",
] as const) {
assert.equal(canApplyDedicatedResumeProgress(status), false, status);
}
for (const status of ["pending", "queued", "transferring"] as const) {
assert.equal(canApplyDedicatedResumeProgress(status), true, status);
}
});
test("late animation-frame progress cannot revive any settled resume state", () => {
const settledStatuses = [
"pausing",
"paused",
"completed",
"failed",
"cancelled",
"attention",
"interrupted",
] as const;
for (const settledStatus of settledStatuses) {
let status: TransferTask["status"] = "transferring";
let scheduled: FrameRequestCallback | undefined;
const applied: number[] = [];
const batcher = createDedicatedResumeProgressBatcher<number>({
requestFrame: (callback) => {
scheduled = callback;
return 41;
},
cancelFrame: () => undefined,
canApply: () => canApplyDedicatedResumeProgress(status),
apply: (progress) => applied.push(progress),
});
batcher.push(7);
status = settledStatus;
scheduled?.(0);
assert.deepEqual(applied, [], settledStatus);
}
});
test("finishing a resume flushes once and rejects raced or future progress", () => {
let status: TransferTask["status"] = "transferring";
let scheduled: FrameRequestCallback | undefined;
const cancelledHandles: number[] = [];
const applied: number[] = [];
const batcher = createDedicatedResumeProgressBatcher<number>({
requestFrame: (callback) => {
scheduled = callback;
return 73;
},
cancelFrame: (handle) => cancelledHandles.push(handle),
canApply: () => canApplyDedicatedResumeProgress(status),
apply: (progress) => applied.push(progress),
});
batcher.push(11);
batcher.push(12);
batcher.finish();
status = "completed";
scheduled?.(0); // Simulate a frame already dequeued when it was cancelled.
batcher.push(13);
batcher.finish();
assert.deepEqual(applied, [12]);
assert.deepEqual(cancelledHandles, [73]);
});
test("50,000 retained child updates use a hard-bounded number of store scans", () => {
const retained = new Set(Array.from({ length: 50_000 }, (_, index) => `child-${index}`));
const batches: TransferTask[][] = [];
const batcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 50_001,
hasTask: (taskId) => retained.has(taskId),
upsertTasks: (tasks) => batches.push([...tasks]),
});
for (let index = 0; index < 50_000; index += 1) {
const child = {
id: `child-${index}`,
status: "transferring",
parentTaskId: "parent",
} as TransferTask;
batcher.push(child);
batcher.push({ ...child, status: "completed" });
}
batcher.flush();
assert.ok(
batches.length <= Math.ceil(50_000 / DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE),
`expected bounded store scans, got ${batches.length}`,
);
const finalById = new Map<string, TransferTask>();
for (const task of batches.flat()) finalById.set(task.id, task);
assert.equal(finalById.size, 50_000);
assert.ok([...finalById.values()].every((task) => task.status === "completed"));
assert.ok(
batches.flat().length <= 50_000 + batches.length,
"a batch-boundary transition may repeat at most one child per store scan",
);
});

View File

@@ -0,0 +1,100 @@
import type { TransferStatus, TransferTask } from "../../domain/models";
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
export interface DedicatedResumeChildUpdateBatcher {
push(task: TransferTask): void;
flush(): void;
}
export interface DedicatedResumeProgressBatcher<T> {
push(progress: T): void;
finish(): void;
}
/**
* A restarted directory can retain tens of thousands of exception rows. The
* store intentionally performs full history compaction on each upsert, so
* feeding it one child transition at a time becomes quadratic. Keep only the
* latest state for each retained child and compact in fixed-size batches.
*/
export function createDedicatedResumeChildUpdateBatcher(deps: {
getTaskCount: () => number;
hasTask: (taskId: string) => boolean;
upsertTasks: (tasks: readonly TransferTask[]) => void;
}): DedicatedResumeChildUpdateBatcher {
const pending = new Map<string, TransferTask>();
const flush = () => {
if (pending.size === 0) return;
const batch = [...pending.values()];
pending.clear();
deps.upsertTasks(batch);
};
return {
push(task) {
const shouldBatch = !!task.parentTaskId
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
&& deps.hasTask(task.id);
if (!shouldBatch) {
deps.upsertTasks([task]);
return;
}
pending.set(task.id, task);
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
},
flush,
};
}
/** Only rows still owned by an active resume may accept a deferred rAF sample. */
export function canApplyDedicatedResumeProgress(status: TransferStatus): boolean {
return status === "pending" || status === "queued" || status === "transferring";
}
/**
* Coalesce renderer progress without letting a callback outlive the resume
* invocation that scheduled it. finish() preserves the newest sample once,
* cancels the scheduled paint, and permanently rejects late callbacks.
*/
export function createDedicatedResumeProgressBatcher<T>(deps: {
requestFrame: (callback: FrameRequestCallback) => number;
cancelFrame: (handle: number) => void;
canApply: () => boolean;
apply: (progress: T) => void;
}): DedicatedResumeProgressBatcher<T> {
let pending: T | undefined;
let frame: number | null = null;
let finished = false;
const applyPending = () => {
const progress = pending;
pending = undefined;
if (progress !== undefined && deps.canApply()) deps.apply(progress);
};
const flushFrame = () => {
frame = null;
if (finished) return;
applyPending();
};
return {
push(progress) {
if (finished) return;
pending = progress;
if (frame == null) frame = deps.requestFrame(flushFrame);
},
finish() {
if (finished) return;
finished = true;
if (frame != null) {
deps.cancelFrame(frame);
frame = null;
}
// Preserve the final durable checkpoint while the row is still active.
// The caller can now publish its completed/failed/attention result with
// no scheduled callback left that could overwrite the terminal state.
applyPending();
},
};
}

View File

@@ -0,0 +1,210 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { useI18n } from '../../i18n/I18nProvider';
import { useCustomThemes } from '../../state/customThemeStore';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { toEditorTabId } from '../../state/activeTabStore';
import {
getSessionSnapshotActions,
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import {
getTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellChrome,
publishAppShellDomainSlice,
} from '../appShellPropsStore';
import {
getThemeRuntimeActions,
subscribeThemeRuntimeActions,
} from '../themeRuntimeBridge';
const IS_MAC_CLIENT =
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
/**
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
* appearance chrome, and selective session/vault snapshot fields. Assembles
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
*/
export function ChromeHost() {
const { t } = useI18n();
const settingsChrome = useSettingsChromeStore();
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const terminalSettings = useTerminalSettingsStore();
const editorTabs = useEditorTabChromeList();
const pluginViewTabs = usePluginViewTabs();
void pluginViewTabs;
const customThemes = useCustomThemes();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const themeRuntime = useSyncExternalStore(
subscribeThemeRuntimeActions,
getThemeRuntimeActions,
getThemeRuntimeActions,
);
const orphanSessionsForShellRef = useRef(session.orphanSessions);
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
orphanSessionsForShellRef.current,
session.orphanSessions as never,
);
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const sessionById = useMemo(
() => new Map(session.sessions.map((s) => [s.id, s])),
[session.sessions],
);
const workspaceById = useMemo(
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
[session.workspaces],
);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
const pluginViewTabIds = useMemo(
() => pluginViewTabs.map((tab) => tab.id),
[pluginViewTabs],
);
const additionalWorkTabIds = useMemo(
() => [...editorTabTopIds, ...pluginViewTabIds],
[editorTabTopIds, pluginViewTabIds],
);
const orderedTabsWithEditors = useMemo(
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
[additionalWorkTabIds, sessionActions],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
}, [additionalWorkTabIds, sessionActions]);
const chromeDomain = useMemo(() => {
if (!handlers) return null;
return {
closeLogView: sessionActions?.closeLogView,
handleEndSessionDrag: handlers.handleEndSessionDrag,
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
handleOpenSettings: handlers.handleOpenSettings,
handleRootContextMenu: handlers.handleRootContextMenu,
handleSyncNowManual: handlers.handleSyncNowManual,
isMacClient: IS_MAC_CLIENT,
logViews: session.logViews,
openLogView: sessionActions?.openLogView,
orderedTabsWithEditors,
orphanSessions: orphanSessionsForShell,
reorderWorkTabs,
resetSessionRename: sessionActions?.resetSessionRename,
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
sessionRenameTarget: session.sessionRenameTarget,
setActiveTabId: sessionActions?.setActiveTabId,
startSessionRename: sessionActions?.startSessionRename,
renameSessionInline: sessionActions?.renameSessionInline,
startWorkspaceRename: sessionActions?.startWorkspaceRename,
submitSessionRename: sessionActions?.submitSessionRename,
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
t,
themeById,
workspaceRenameTarget: session.workspaceRenameTarget,
};
}, [
handlers,
orderedTabsWithEditors,
orphanSessionsForShell,
reorderWorkTabs,
session.logViews,
session.sessionRenameTarget,
session.workspaceRenameTarget,
sessionActions,
t,
themeById,
]);
// Call-time getters so chrome can publish before Publisher layout effects
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
// the first Host publish (startup TypeError under StrictMode).
const setActiveTabId = useCallback((id: string) => {
getSessionSnapshotActions()?.setActiveTabId?.(id);
}, []);
const applyAppTheme = useCallback(() => {
getTerminalSettingsActions()?.applyAppTheme?.();
}, []);
const appShellChrome = useMemo(() => {
if (!handlers) return null;
// Theme runtime actions may still be null on the first paint before
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
// with a missing resolveSessionAppearance / currentTerminalTheme.
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
return null;
}
return {
showSftpTab: settingsChrome.showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme: themeRuntime.currentTerminalTheme,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
editorTabs,
logViews: session.logViews,
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
t,
};
}, [
applyAppTheme,
editorTabs,
handlers,
hostById,
session.logViews,
sessionById,
setActiveTabId,
settingsChrome.showSftpTab,
t,
terminalSettings.followAppTerminalTheme,
themeById,
themeRuntime,
workspaceById,
]);
useLayoutEffect(() => {
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
}, [appShellChrome, chromeDomain]);
return null;
}

View File

@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
publishAppShellOverlays,
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
addToWorkspaceDialog: local.addToWorkspaceDialog,
clearAndRemoveSource: handlers.clearAndRemoveSource,
clearAndRemoveSources: handlers.clearAndRemoveSources,
editorTabs,
emptyVaultConflict: local.emptyVaultConflict,
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
handlers,
local.addToWorkspaceDialog,
local.emptyVaultConflict,
local.isCreateWorkspaceOpen,
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
if (!handlers) return null;
return {
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
deleteHostConfirm: local.deleteHostConfirm,
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
};
}, [handlers, local.deleteHostConfirm]);
useLayoutEffect(() => {
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
if (overlays) publishAppShellOverlays(overlays as never);
}, [dialogsDomain, overlays]);
return null;
}

View File

@@ -0,0 +1,283 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
import { useCustomThemes } from '../../state/customThemeStore';
import {
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../../state/settingsChromeStore';
import {
useTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
/**
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
* owned here, glue handlers from the app handlers bridge. Assembles the full
* terminal domain bag field-by-field — never spreads a prepared bag.
*/
export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
followAppTerminalTheme,
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
} = terminalSettings;
const settingsChrome = useSettingsChromeStore();
const settingsChromeActions = useSettingsChromeActions();
const appearance = useAppearanceChromeStore();
const customThemes = useCustomThemes();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
// Call-time getters: SettingsPublisher registers the runtime in a layout
// effect after this Host's first render. Capturing noop setters into
// useThemeRuntime would permanently drop follow-app UI theme persistence.
const setLightUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
}, []);
const setDarkUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
}, []);
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme: settingsChrome.resolvedTheme,
lightUiThemeId: settingsChrome.lightUiThemeId,
darkUiThemeId: settingsChrome.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
customThemes,
setTheme: settingsChromeActions.setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
const {
globalAppearance,
accentedGlobalAppearance,
clearIntent: clearThemeIntent,
settleManualIntent: settleManualThemeIntent,
pickTheme: pickTerminalTheme,
resolveFocusedAppearance,
currentTerminalTheme,
} = themeRuntime;
// Inject live accent into CSS vars without publishing accented theme identity
// into the terminal domain bag (accent drag must not rebuild AppShell).
useTerminalAppearanceInjection(accentedGlobalAppearance, {
includeChromeSurfaces: followAppTerminalTheme,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
clearThemeIntent();
}, [followAppTerminalTheme, clearThemeIntent]);
// Bridge exposes the stable base theme only — ChromeHost must not republish
// when accentedGlobalAppearance identity churns during color-picker drag.
const themeBridgeActions = useMemo(() => ({
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
currentTerminalTheme,
globalAppearance,
}), [
clearThemeIntent,
currentTerminalTheme,
globalAppearance,
pickTerminalTheme,
resolveFocusedAppearance,
settleManualThemeIntent,
]);
useLayoutEffect(() => {
registerThemeRuntimeActions(themeBridgeActions);
return () => {
registerThemeRuntimeActions(null);
};
}, [themeBridgeActions]);
const sessionsForShellRef = useRef(session.sessions);
const sessionsForShell = retainStableSessionsIgnoringPresentation(
sessionsForShellRef.current,
session.sessions as never,
);
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const terminalHosts = useMemo(
() => (
local.ephemeralHosts.length > 0
? [...vault.hosts, ...local.ephemeralHosts]
: vault.hosts
),
[local.ephemeralHosts, vault.hosts],
);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
// Persist the default theme for ephemeral/manual hosts. Mode overrides
// reset to auto so the chosen theme becomes the new baseline for the
// current resolved UI mode (same behavior as pre-Host App).
terminalSettingsActions?.setTerminalThemeId(themeId);
if (settingsChrome.resolvedTheme === 'dark') {
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
} else {
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
}
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
pickTerminalTheme(themeId);
}, [pickTerminalTheme]);
const terminalDomain = useMemo(() => {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
closeSession: sessionActions?.closeSession,
closeTabsBatch: handlers.closeTabsBatch,
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
duplicateSessionWithCurrentShell: handlers.duplicateSessionWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
currentTerminalTheme,
draggingSessionId: session.draggingSessionId,
editorWordWrap: terminalSettings.editorWordWrap,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveSessionAppearance: resolveFocusedAppearance,
handleConnectSerial: handlers.handleConnectSerial,
handleConnectToHost: handlers.handleConnectToHost,
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handleHotkeyAction: handlers.handleHotkeyAction,
handleSessionStatusChange: handlers.handleSessionStatusChange,
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
hostById,
terminalHosts,
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
runSnippet: handlers.runSnippet,
sessionLogsDir: terminalSettings.sessionLogsDir,
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
sessionLogsFormat: terminalSettings.sessionLogsFormat,
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
sessions: sessionsForShell,
setDraggingSessionId: sessionActions?.setDraggingSessionId,
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
sftpAutoSync: terminalSettings.sftpAutoSync,
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
terminalFontSize: terminalSettings.terminalFontSize,
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
workspaces: session.workspaces,
};
}, [
clearThemeIntent,
currentTerminalTheme,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handlers,
hostById,
local.openNoteRequest,
local.portForwardingRules,
pickTerminalTheme,
resolveFocusedAppearance,
session.draggingSessionId,
session.workspaces,
sessionActions,
sessionsForShell,
settleManualThemeIntent,
terminalHosts,
terminalSettings,
terminalSettingsActions,
]);
useLayoutEffect(() => {
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
}, [terminalDomain]);
return null;
}

View File

@@ -0,0 +1,105 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
/**
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
* full vault domain bag field-by-field — never spreads a prepared bag from
* AppSideEffects.
*/
export function VaultHost() {
const vault = useVaultSnapshot();
const actions = useVaultSnapshotActions();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
[vault.knownHosts],
);
const vaultDomain = useMemo(() => {
if (!handlers) return null;
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
effectiveKnownHosts,
groupConfigs: vault.groupConfigs,
handleAddKnownHost: handlers.handleAddKnownHost,
handleDeleteHost: handlers.handleDeleteHost,
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
hosts: vault.hosts,
identities: vault.identities,
importOrReuseKey: actions?.importOrReuseKey,
keys: vault.keys,
managedSources: vault.managedSources,
navigateToSection: local.navigateToSection,
proxyProfiles: vault.proxyProfiles,
readPersistedHosts: actions?.readPersistedHosts,
readPersistedManagedSources: actions?.readPersistedManagedSources,
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
setNavigateToSection: handlers.setNavigateToSection,
setVaultFocusRequest: handlers.setVaultFocusRequest,
snippetPackages: vault.snippetPackages,
snippets: vault.snippets,
unmanageSource: handlers.unmanageSource,
updateCustomGroups: actions?.updateCustomGroups,
updateGroupConfigs: actions?.updateGroupConfigs,
updateHosts: actions?.updateHosts,
updateIdentities: actions?.updateIdentities,
updateKeys: actions?.updateKeys,
updateKnownHosts: actions?.updateKnownHosts,
updateManagedSources: actions?.updateManagedSources,
updateProxyProfiles: actions?.updateProxyProfiles,
updateSnippetPackages: actions?.updateSnippetPackages,
updateSnippets: actions?.updateSnippets,
vaultFocusRequest: local.vaultFocusRequest,
};
}, [
actions,
effectiveKnownHosts,
handlers,
local.deepLinkHostDraft,
local.navigateToSection,
local.vaultFocusRequest,
vault.customGroups,
vault.groupConfigs,
vault.hosts,
vault.identities,
vault.keys,
vault.managedSources,
vault.proxyProfiles,
vault.snippetPackages,
vault.snippets,
]);
useLayoutEffect(() => {
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
}, [vaultDomain]);
return null;
}

View File

@@ -0,0 +1,9 @@
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
/** Lazy mount wrappers — stable module identity for the app lifetime. */
export const APP_MOUNTS_DOMAIN = Object.freeze({
VaultViewContainer,
SftpViewMount,
TerminalLayerMount,
LogViewWrapper,
});

View File

@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
removeKeyboardInteractiveRequest,
shouldQueueKeyboardInteractiveRequest,
} from "./useAppStartupEffects.ts";
import {
clearTerminalBootEpoch,
setTerminalBootEpoch,
} from "../../domain/terminalBootEpoch.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
false,
);
});
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "disconnected" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
});
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
setTerminalBootEpoch("terminal-1", 3);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
[{ id: "terminal-1", status: "connecting" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
clearTerminalBootEpoch("terminal-1");
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
true,
);
});
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
});
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
});
test("legacy unscoped keyboard-interactive requests remain visible", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
true,
);
});
test("cancelled keyboard-interactive requests are removed from the renderer queue", () => {
const queue = [{ requestId: "keep" }, { requestId: "cancel" }];
assert.deepEqual(removeKeyboardInteractiveRequest(queue, "cancel"), [{ requestId: "keep" }]);
});

View File

@@ -0,0 +1,53 @@
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
import {
AppLockChromeContext,
registerAppAppLockRuntime,
type AppAppLockRuntime,
} from '../../state/appRuntimeBridge';
export type AppLockRuntimePublisherProps = {
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
appLock: AppAppLockRuntime;
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
appLockEnabled: boolean;
children?: ReactNode;
};
/**
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
* slice goes on context so TopTabs / AppSideEffects re-render only when the
* lock state actually changes — the full runtime changes identity on every
* gate render.
*/
export function AppLockRuntimePublisher({
appLock,
appLockEnabled,
children,
}: AppLockRuntimePublisherProps) {
useLayoutEffect(() => {
registerAppAppLockRuntime(appLock);
}, [appLock]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppAppLockRuntime(null);
}, []);
const chrome = useMemo(
() => ({
appLockEnabled,
locked: appLock.locked,
initialized: appLock.initialized,
}),
[appLockEnabled, appLock.locked, appLock.initialized],
);
return (
<AppLockChromeContext.Provider value={chrome}>
{children}
</AppLockChromeContext.Provider>
);
}

View File

@@ -0,0 +1,226 @@
import { useLayoutEffect, useMemo } from 'react';
import {
AppSessionRuntimeContext,
registerAppSessionRuntime,
} from '../../state/appRuntimeBridge';
import {
publishSessionSnapshot,
registerSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSessionState } from '../../state/useSessionState';
import type { ReactNode } from 'react';
export type SessionPublisherProps = {
/** Peer session windows must not write the main window's restore record. */
persistSessionRestore: boolean;
children?: ReactNode;
};
/**
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
*/
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
const session = useSessionState({ persistSessionRestore });
const {
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
} = session;
useLayoutEffect(() => {
registerAppSessionRuntime(session);
}, [session]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSessionRuntime(null);
}, []);
useLayoutEffect(() => {
publishSessionSnapshot({
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
});
}, [
draggingSessionId,
logViews,
orphanSessions,
sessionRenameTarget,
sessions,
workspaceRenameTarget,
workspaces,
]);
const sessionActions = useMemo(() => ({
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
closeSession,
closeSessions,
closeWorkspace,
connectToHost,
copySession,
copyWorkspace,
createLocalTerminal,
createSerialSession,
createSessionFromCloneSource,
createWorkspaceFromSessions,
createWorkspaceFromTargets,
createWorkspaceWithHosts,
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
reorderWorkspaceSessions,
resetSessionRename,
resetWorkspaceRename,
runSnippet,
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
splitSession,
startSessionRename,
startWorkspaceRename,
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
updateSessionFontSize,
updateSessionRestoreCwd,
updateSessionStatus,
updateSplitSizes,
]);
useLayoutEffect(() => {
registerSessionSnapshotActions(sessionActions);
return () => {
registerSessionSnapshotActions(null);
};
}, [sessionActions]);
return (
<AppSessionRuntimeContext.Provider value={session}>
{children}
</AppSessionRuntimeContext.Provider>
);
}

View File

@@ -0,0 +1,43 @@
import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
type AppSettingsRuntime,
} from '../../state/appRuntimeBridge';
export type SettingsPublisherProps = {
/**
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
* owns `useSettingsState` so the lock overlay can render before app children
* mount; the publisher only binds the runtime slot and context.
*/
settings: AppSettingsRuntime;
children?: ReactNode;
};
/**
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
* `SessionPublisher` hand over their runtimes: a context for render-time reads
* and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the
* binding out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSettingsRuntime(null);
}, []);
return (
<AppSettingsRuntimeContext.Provider value={settings}>
{children}
</AppSettingsRuntimeContext.Provider>
);
}

View File

@@ -0,0 +1,200 @@
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
import {
AppVaultRuntimeContext,
registerAppVaultRuntime,
type AppVaultContextValue,
} from '../../state/appRuntimeBridge';
import {
publishVaultSnapshot,
registerVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { useVaultState } from '../../state/useVaultState';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
export type VaultPublisherProps = {
children?: ReactNode;
};
function vaultContextValuesEqual(
prev: AppVaultContextValue,
next: AppVaultContextValue,
): boolean {
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
return keys.every((key) => prev[key] === next[key]);
}
/**
* Owns `useVaultState` and publishes it three ways: the catalog into
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
* mutators into the same store's action slot, and a **catalog-only** runtime
* onto `AppVaultRuntimeContext` for App.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
* absent from the context value: they churn on a different cadence and already
* fan out through dedicated stores. Including them would force App (the
* domain-bag builder) to re-render on every note edit or session log append.
* Imperative callers that still need the full hook return use
* `getAppVaultRuntime()`.
*/
export function VaultPublisher({ children }: VaultPublisherProps) {
const vault = useVaultState();
const {
isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
} = vault;
useLayoutEffect(() => {
registerAppVaultRuntime(vault);
}, [vault]);
// Only a real unmount clears the slot. A re-render re-registers through the
// effect above, and StrictMode's simulated remount re-runs both.
useLayoutEffect(() => () => {
registerAppVaultRuntime(null);
}, []);
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
// is briefly empty at boot even when storage has entries. Publish the same
// storage fallback App uses so a subscriber connecting during that window
// does not re-prompt for a fingerprint it already trusts.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
useLayoutEffect(() => {
publishVaultSnapshot({
isVaultInitialized: isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts: effectiveKnownHosts,
managedSources,
groupConfigs,
});
}, [
customGroups,
effectiveKnownHosts,
groupConfigs,
hosts,
identities,
isInitialized,
keys,
managedSources,
proxyProfiles,
snippetPackages,
snippets,
]);
useLayoutEffect(() => {
registerVaultSnapshotActions({
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
readPersistedManagedSources,
removeShellHistoryEntry,
updateCustomGroups,
updateGroupConfigs,
updateHostDistro,
updateHostLastConnected,
updateHosts,
updateIdentities,
updateKeys,
updateKnownHosts,
updateManagedSources,
updateProxyProfiles,
updateSnippetPackages,
updateSnippets,
]);
// Strip high-churn fields, then retain the previous object identity when only
// those fields (or exportData) changed so React context consumers stay quiet.
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
const vaultForApp = useMemo((): AppVaultContextValue => {
const {
notes: _notes,
noteGroups: _noteGroups,
connectionLogs: _connectionLogs,
shellHistory: _shellHistory,
exportData: _exportData,
...catalog
} = vault;
const prev = vaultForAppRef.current;
if (prev && vaultContextValuesEqual(prev, catalog)) {
return prev;
}
vaultForAppRef.current = catalog;
return catalog;
}, [vault]);
return (
<AppVaultRuntimeContext.Provider value={vaultForApp}>
{children}
</AppVaultRuntimeContext.Provider>
);
}

View File

@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
const startupEffectsSource = readFileSync(
new URL('./useAppStartupEffects.ts', import.meta.url),
'utf8',
);
const updateCheckSource = readFileSync(
new URL('../state/useUpdateCheck.ts', import.meta.url),
'utf8',
);
const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
const appLockBridgeSource = readFileSync(
new URL('../state/useAppLockBridge.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
const renderAt = indexSource.indexOf('root.render(', cursor);
assert.notEqual(renderAt, -1);
const opener = indexSource.slice(renderAt, renderAt + 60);
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
cursor = renderAt + 'root.render('.length;
}
});
test('clone-session payload is consumed once even if the effect re-runs', () => {
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
const effectEnd = appSideEffectsSource.indexOf(
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
effectStart,
);
assert.notEqual(effectEnd, -1);
const body = appSideEffectsSource.slice(effectStart, effectEnd);
// A ref comparison is required: clearing the state only lands on the next
// render, so a re-invoked effect still closes over the same payload.
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
assert.ok(
body.indexOf('consumedNewWindowSessionRef.current = pending')
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
'payload must be marked consumed before the clone is created',
);
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
assert.notEqual(guardAt, -1);
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
guarded.indexOf('rendererReadySent = true;')
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
'the latch must be set before the IPC call so a re-entrant effect is blocked',
);
});
test('update-available toast latches on the release version', () => {
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
assert.notEqual(latchAt, -1);
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
assert.notEqual(toastAt, -1);
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
});
test('port-forward auto-start runs once across a StrictMode double effect', () => {
assert.match(
portForwardingAutoStartSource,
/const autoStartExecutedRef = useRef\(false\);/,
'the launch auto-start needs a module-render latch, not just an effect dep list',
);
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
const effectEnd = portForwardingAutoStartSource.indexOf(
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
effectStart,
);
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
// StrictMode invokes the effect twice with the same render's closure, so the
// latch has to be written before the async run is kicked off — awaiting or
// deferring the write would let the second invoke start a duplicate tunnel.
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
const runAt = body.indexOf('void runAutoStart();');
assert.notEqual(latchAt, -1);
assert.notEqual(runAt, -1);
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
// The vault gate must also sit before the latch: latching on a pre-hydration
// invoke would permanently suppress the real auto-start.
assert.ok(
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
'the vault gate must precede the latch write',
);
});
test('cancelled startup update check resets its latch instead of skipping forever', () => {
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
assert.notEqual(scheduleAt, -1);
const tail = updateCheckSource.slice(scheduleAt);
// The latch is only meaningful once the timer actually fires; a cleanup that
// cancels it beforehand must let the next effect schedule again.
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
});
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
assert.match(preloadSource, /lastPayload:\s*null/);
assert.match(
preloadSource,
/terminalPopupConfigState\.lastPayload = payload/,
'incoming popup config must be retained beyond the one-shot pending slot',
);
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
assert.notEqual(subscribeAt, -1);
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
assert.match(
subscribe,
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
'resubscribe must replay lastPayload after pending was drained',
);
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
assert.doesNotMatch(
subscribe,
/terminalPopupConfigState\.lastPayload = null/,
'StrictMode remount must not clear lastPayload on subscribe',
);
});
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
const vaultSource = readFileSync(
new URL('../state/useVaultState.ts', import.meta.url),
'utf8',
);
const initAt = vaultSource.indexOf('let cancelled = false;');
assert.notEqual(initAt, -1, 'vault init must track cancellation');
const initSlice = vaultSource.slice(initAt, initAt + 12000);
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
assert.match(
initSlice,
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
'only the surviving init may mark the vault ready',
);
assert.match(initSlice, /if \(cancelled\) return;/);
});
test('global hotkey registration cleans up across StrictMode remount', () => {
const systemEffectsSource = readFileSync(
new URL('../state/systemSettingsEffects.ts', import.meta.url),
'utf8',
);
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
assert.notEqual(hotkeyAt, -1);
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
assert.match(hotkeyEffect, /let cancelled = false;/);
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
assert.match(
hotkeyEffect,
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
'cleanup must unregister a registration started by this effect',
);
// Early return before notify must not skip returning the cleanup function.
assert.doesNotMatch(
hotkeyEffect,
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
);
});
test('settings persistMountedRef resets on StrictMode cleanup', () => {
const settingsSource = readFileSync(
new URL('../state/useSettingsState.ts', import.meta.url),
'utf8',
);
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
assert.notEqual(markAt, -1);
const markEffect = settingsSource.slice(markAt, markAt + 500);
assert.match(markEffect, /persistMountedRef\.current = true;/);
assert.match(
markEffect,
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
'remount must treat boot as a fresh mount, not a settings change',
);
});
test('tray panel connect flush latches against StrictMode double invoke', () => {
const sideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
assert.match(
sideEffectsSource,
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
);
});
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
const settingsSource = readFileSync(
new URL('../state/useSettingsState.ts', import.meta.url),
'utf8',
);
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
assert.match(
settingsSource,
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
);
assert.match(
settingsSource,
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
);
});
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
const hostSource = readFileSync(
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
'utf8',
);
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
assert.doesNotMatch(
hostSource,
/consumedTerminalSelectionRequestIdRef/,
'component refs reset on StrictMode remount; use a module Set',
);
assert.match(
hostSource,
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
);
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
});
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
const panelSource = readFileSync(
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
'utf8',
);
assert.doesNotMatch(
panelSource,
/setupCodexAppServerInteractionBridge/,
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
);
});

View File

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});
test('editor tabs already present in native ordering are not appended twice', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'editor:file-1', 'plugin-view:one'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'editor:file-1', 'plugin-view:one'],
);
});
test('pinned tabs cannot be duplicated by a malformed persisted work ordering', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['vault', 'session-1', 'sftp'],
editorTabIds: [],
}),
['vault', 'sftp', 'session-1'],
);
});
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.get('vault'), 1);
assert.equal(map.get('sftp'), 2);
assert.equal(map.get('session-1'), 3);
assert.equal(map.get('workspace-1'), 4);
});
test('shortcut number map skips pinned tabs in shell-only mode', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.has('vault'), false);
assert.equal(map.has('sftp'), false);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('workspace-1'), 2);
});
test('shortcut number map caps at nine entries', () => {
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
const map = buildTabShortcutNumberById({
showSftpTab: false,
shellOnlyTabNumberShortcuts: true,
orderedTabs,
editorTabIds: [],
});
assert.equal(map.size, 9);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('session-9'), 9);
assert.equal(map.has('session-10'), false);
});

View File

@@ -0,0 +1,33 @@
/** Tab ids targeted by keyboard tab navigation shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...new Set([...params.orderedTabs, ...params.editorTabIds])];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...new Set([...pinnedTabs, ...workTabs])];
}
/**
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
* Only the first nine shortcut targets receive a number.
*/
export function buildTabShortcutNumberById(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): ReadonlyMap<string, number> {
const targets = buildNumberShortcutTabTargets(params);
const map = new Map<string, number>();
const limit = Math.min(9, targets.length);
for (let index = 0; index < limit; index += 1) {
map.set(targets[index], index + 1);
}
return map;
}

View File

@@ -0,0 +1,52 @@
/**
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
* AppSideEffects handlers (default/follow theme changes) call through this
* bridge instead of co-hosting the hook.
*/
type Listener = () => void;
export type ThemeRuntimeBridgeActions = {
clearThemeIntent: () => void;
settleManualThemeIntent: () => void;
pickTerminalTheme: (themeId: string) => void;
resolveFocusedAppearance: (...args: never[]) => unknown;
currentTerminalTheme: unknown;
globalAppearance: unknown;
};
class ThemeRuntimeBridge {
private actions: ThemeRuntimeBridgeActions | null = null;
private listeners = new Set<Listener>();
get = (): ThemeRuntimeBridgeActions | null => this.actions;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: ThemeRuntimeBridgeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new ThemeRuntimeBridge();
export function registerThemeRuntimeActions(
actions: ThemeRuntimeBridgeActions | null,
): void {
bridge.set(actions);
}
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
return bridge.get();
}
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
return bridge.subscribe(listener);
}

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});
test("top tabs chrome theme keeps accent foreground in sync", () => {
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
assert.match(source, /--primary-foreground/);
assert.match(source, /--accent-foreground/);
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
});

View File

@@ -0,0 +1,115 @@
import type { TerminalTheme } from '../../types';
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const TOP_TABS_THEME_PROPERTIES = [
'--top-tabs-bg',
'--top-tabs-fg',
'--top-tabs-muted',
'--top-tabs-active-bg',
'--top-tabs-accent',
'--background',
'--foreground',
'--accent',
'--accent-foreground',
'--primary',
'--primary-foreground',
'--secondary',
'--border',
'--muted-foreground',
] as const;
let topTabsChromeThemeVarsApplied = false;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
if (!topTabsChromeThemeVarsApplied) return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const accentForeground = resolveReadableForegroundForHsl(accent);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}

View File

@@ -0,0 +1,579 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { usePortForwardingAutoStart } from '../state/usePortForwardingAutoStart';
import { editorTabStore } from '../state/editorTabStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import type { TransferTask } from '../../domain/models';
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import {
canApplyDedicatedResumeProgress,
createDedicatedResumeChildUpdateBatcher,
createDedicatedResumeProgressBatcher,
} from './dedicatedResumeProgress';
type StartupEffectsContext = Record<string, any>;
type KeyboardInteractiveScope = "terminal" | "external";
type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
hostId?: string;
requestId?: string;
bootEpoch?: number;
};
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
type KeyboardInteractiveQueueItem = { requestId: string };
export function shouldQueueKeyboardInteractiveRequest(
request: KeyboardInteractiveRequestLike,
sessions: SessionIdLike[],
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
const session = sessions.find((entry) => entry.id === request.sessionId);
if (!session) return false;
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
if (session.status === "disconnected") return false;
// After disconnect → reconnect the tab is connecting again; reject MFA from
// a superseded SSH start that still shares this sessionId.
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
return true;
}
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
queue: T[],
requestId: string,
): T[] {
return queue.filter(request => request.requestId !== requestId);
}
export function useAppStartupEffects(ctx: StartupEffectsContext) {
const {dismissUpdate, enabled = true, groupConfigs, hosts, resumeHosts, identities,
hasRuntimeTunnel, installUpdate, isVaultInitialized, keys, knownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
t, terminalSettings, updateState, workspaces,
} = ctx;
// Vault hosts for tray/menu; resumeHosts may include ephemeral quick-connect rows.
const dedicatedResumeHosts = resumeHosts ?? hosts;
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// After app restart (or soft-resume miss), unfinished transfers reconnect via
// a dedicated SFTP session. Prefer resumeHosts (vault + ephemeral) so
// quick-connect transfers can re-auth without "Cannot find host in your vault".
useEffect(() => {
if (!enabled || !isVaultInitialized) {
sftpTransferCenterStore.setDedicatedResumeHandler(null);
return;
}
sftpTransferCenterStore.setDedicatedResumeHandler(async (task) => {
// Keep reconnectRequired true until the first progress/completion so the
// play control stays a spinner during auth + session setup.
sftpTransferCenterStore.patchTask(task.id, {
status: "pending",
error: undefined,
reconnectRequired: true,
speed: 0,
phase: undefined,
});
const children = sftpTransferCenterStore.getSnapshot().tasks.filter(
(row) => row.parentTaskId === task.id,
);
// rAF-coalesce progress so dedicated resume does not flood the global center.
type ProgressSample = {
transferred: number;
total: number;
speed: number;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
sourceFingerprint?: string;
};
// One rAF coalesce only — main process already time-throttles IPC.
// A second 500ms timer here made dedicated-resume bars jump.
const applyProgress = (progress: ProgressSample) => {
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
if (!current || current.status === "cancelled") return;
if (current.status === "pausing" || current.status === "paused") {
if (hasNewSourceFingerprint(current.sourceFingerprint, progress.sourceFingerprint)) {
sftpTransferCenterStore.patchTask(task.id, { sourceFingerprint: progress.sourceFingerprint });
}
return;
}
// The final sample can still be queued in requestAnimationFrame after
// the resume promise settles. Never let it turn a completed/failed row
// back into a permanently "transferring" task.
if (!canApplyDedicatedResumeProgress(current.status)) return;
// Directory parents use file-count progress; single files use bytes.
// Prefer durable contiguous checkpoint when the bridge supplies it.
const durableCheckpoint = task.isDirectory
? progress.transferred
: (progress.checkpointBytes ?? progress.transferred);
// Keep progress monotonic so a late force-checkpoint paint cannot hide
// later bytes, and the bar never freezes at the pre-quit offset.
const nextTransferred = Math.max(current.transferredBytes ?? 0, progress.transferred);
const nextCheckpoint = task.isDirectory
? Math.max(current.checkpointBytes ?? 0, progress.transferred)
: Math.max(current.checkpointBytes ?? 0, durableCheckpoint);
sftpTransferCenterStore.patchTask(task.id, {
status: "transferring",
transferredBytes: nextTransferred,
...(progress.total > 0 ? { totalBytes: progress.total } : {}),
speed: progress.speed,
...(task.isDirectory
? { checkpointBytes: nextCheckpoint, progressMode: "files" as const }
: {
checkpointBytes: nextCheckpoint,
resumeStage: progress.resumeStage,
downloadCheckpointBytes: progress.downloadCheckpointBytes,
uploadCheckpointBytes: progress.uploadCheckpointBytes,
sourceFingerprint: progress.sourceFingerprint,
}),
reconnectRequired: false,
error: undefined,
phase: "transferring",
ownerId: "dedicated-resume",
});
};
const progressBatcher = createDedicatedResumeProgressBatcher<ProgressSample>({
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (handle) => window.cancelAnimationFrame(handle),
canApply: () => {
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
return !!current && canApplyDedicatedResumeProgress(current.status);
},
apply: applyProgress,
});
const childUpdateBatcher = createDedicatedResumeChildUpdateBatcher({
// Use the restart snapshot, not repeated linear store lookups. Completed
// rows disappear as batches compact, but later updates for those ids can
// still stay in the same bounded batching path safely.
getTaskCount: () => children.length + 1,
hasTask: (() => {
const retainedChildIds = new Set(children.map((child) => child.id));
return (taskId: string) => retainedChildIds.has(taskId);
})(),
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
let acceptsResumeCallbacks = true;
try {
return await resumeTransferWithDedicatedSession(
task,
{
hosts: dedicatedResumeHosts,
keys,
identities,
knownHosts,
terminalSettings,
},
(progress) => {
if (acceptsResumeCallbacks) progressBatcher.push(progress);
},
{
children,
onChildUpdate: (child) => {
if (acceptsResumeCallbacks) {
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
}
},
onDirectoryCheckpointUpdate: (checkpoint) => {
if (acceptsResumeCallbacks) {
sftpTransferCenterStore.patchTask(task.id, {
directoryResumeCheckpoint: checkpoint,
});
}
},
shouldAbort: () => {
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
// interrupted is the pre-reconnect persisted state — do not abort a
// live dedicated walk just because children/parent still show it.
return !current
|| current.status === "cancelled"
|| current.status === "paused";
},
},
);
} finally {
acceptsResumeCallbacks = false;
progressBatcher.finish();
childUpdateBatcher.flush();
}
});
return () => sftpTransferCenterStore.setDedicatedResumeHandler(null);
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
// Show toast notification when update is available (only when auto-download is idle)
const toastedUpdateVersionRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
if (toastedUpdateVersionRef.current === version) return;
toastedUpdateVersionRef.current = version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.viewInSettings'),
}
);
}
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
if (!enabled) return;
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
enabled,
isVaultInitialized,
hosts,
keys,
identities,
knownHosts,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
const sessionsForTray = sessions.map((s) => {
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
return {
id: s.id,
label: s.hostname,
hostLabel: s.hostLabel,
status: s.status,
workspaceId: s.workspaceId,
workspaceTitle: ws?.title,
aiHidden: s.hiddenFromTabs === true,
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules.map((rule: any) => ({
...rule,
canStop: hasRuntimeTunnel(rule.id),
})),
hosts: hostsForSystemMenu,
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(async () => {
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
try {
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (!hasDirty) {
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
));
if (unfinishedTasks.length > 0) {
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
});
return unsub;
}, [enabled, t]);
useEffect(() => {
const bridge = netcattyBridge.get();
const unsubscribeEvents = bridge?.onGlobalSftpTransferEvent?.((event) => {
sftpTransferCenterStore.ingestBackgroundEvent(event);
});
const restartBackgroundTransfer = async (taskId: string, fromBeginning: boolean) => {
const task = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
if (!task || !bridge?.openSftpForSession || !bridge.startStreamTransfer) return;
const sessionId = task.direction === "upload" ? task.targetConnectionId : task.sourceConnectionId;
if (!sessionId || sessionId === "agent" || sessionId === "local") {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "failed",
transferId: taskId,
error: "The original server session is unavailable",
endedAt: Date.now(),
});
return;
}
let sftpId: string | undefined;
try {
const checkpointBytes = fromBeginning ? 0 : (task.checkpointBytes ?? task.transferredBytes ?? 0);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "queued", transferId: taskId });
// Admit first so agent resume does not pin session-backed SFTP handles
// while waiting for main-process concurrency.
const result = await globalSftpTransferScheduler.run(
"background-agent",
task.id,
getSftpTransferResourceKeys({
sourceHostId: task.sourceHostId,
targetHostId: task.targetHostId,
}),
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
async () => {
sftpId = await bridge.openSftpForSession!(sessionId);
return bridge.startStreamTransfer!({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: task.direction === "upload" ? "local" : "sftp",
targetType: task.direction === "download" ? "local" : "sftp",
sourceSftpId: task.direction === "download" ? sftpId : undefined,
targetSftpId: task.direction === "upload" ? sftpId : undefined,
// Keep host-scoped path gates across session reopen (Codex P1).
sourceHostId: task.sourceHostId,
targetHostId: task.targetHostId,
totalBytes: task.totalBytes,
resumable: task.resumable !== false,
checkpointBytes,
resumeStage: fromBeginning ? undefined : task.resumeStage,
downloadCheckpointBytes: fromBeginning ? 0 : task.downloadCheckpointBytes,
uploadCheckpointBytes: fromBeginning ? 0 : task.uploadCheckpointBytes,
sourceFingerprint: fromBeginning ? undefined : task.sourceFingerprint,
skipAdmission: true,
});
},
);
// Same-id retry stole ownership; wait for the live owner's terminal
// status instead of treating this invoke as completed (Codex P2).
if (result?.superseded === true) {
// Wait for live owner terminal status only (no fixed deadline).
for (;;) {
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed" || status === "cancelled" || status === "failed") {
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "cancelled",
transferId: task.id,
endedAt: Date.now(),
});
}
// completed: events already applied; cancelled handled above.
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
} else if (result?.error) {
throw new Error(result.error);
} else {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: task.id, endedAt: Date.now() });
}
} catch (error) {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "failed",
transferId: task.id,
error: error instanceof Error ? error.message : String(error),
endedAt: Date.now(),
});
} finally {
if (sftpId) await bridge.closeSftp?.(sftpId).catch(() => {});
}
};
const unregisterOwner = sftpTransferCenterStore.registerOwner("background-agent", {
pause: async (taskId) => {
const result = await bridge?.pauseTransfer?.(taskId);
if (result?.success) sftpTransferCenterStore.ingestBackgroundEvent({
type: "paused",
transferId: taskId,
checkpointBytes: result.checkpointBytes,
resumeStage: result.resumeStage,
downloadCheckpointBytes: result.downloadCheckpointBytes,
uploadCheckpointBytes: result.uploadCheckpointBytes,
sourceFingerprint: result.sourceFingerprint,
});
},
resume: async (taskId) => {
const result = await bridge?.resumeTransfer?.(taskId);
if (result?.success) {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "resumed", transferId: taskId });
} else {
sftpTransferCenterStore.markReconnectRequired(
taskId,
result?.reason ?? "The original server connection is unavailable",
);
setTimeout(() => { void sftpTransferCenterStore.resume(taskId); }, 0);
}
},
cancel: async (taskId) => {
await bridge?.cancelTransfer?.(taskId);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: taskId, endedAt: Date.now() });
},
retry: async (taskId) => { await restartBackgroundTransfer(taskId, true); },
prioritize: async (taskId) => { await bridge?.prioritizeTransfer?.(taskId); },
dismiss: (taskId, prunedTask) => {
const task = prunedTask
?? sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
if (!task) return;
void bridge?.cleanupTransferArtifacts?.({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
},
});
return () => {
unsubscribeEvents?.();
unregisterOwner();
};
}, [enabled]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
if (request.scope === "terminal" && request.requestId) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return;
}
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
hostId: request.hostId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
savedPassword: request.savedPassword,
allowSavePassword: request.allowSavePassword !== false,
}]);
});
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
});
const onTerminalDisconnected = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
if (!sessionId) return;
setKeyboardInteractiveQueue((prev) => {
const doomed = prev.filter((request) => request.sessionId === sessionId);
for (const request of doomed) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return prev.filter((request) => request.sessionId !== sessionId);
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
return () => {
unsubscribe?.();
unsubscribeCancelled?.();
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
};
}, [enabled, setKeyboardInteractiveQueue]);
}

View File

@@ -0,0 +1,28 @@
import type React from 'react';
import { useMemo } from 'react';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { useSettingsChromeStore } from '../state/settingsChromeStore';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
/**
* App theme CSS variables for surfaces that need them (vault surface, plugin
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}

View File

@@ -0,0 +1,310 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
shouldOpenHostEditOnWorkSurface,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
id,
name: id,
type,
colors: {
background,
foreground: type === 'dark' ? '#ffffff' : '#000000',
cursor: '#888888',
selection: '#555555',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#444444',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#5555ff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
},
});
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('work tab order removes duplicate ids before rendering', () => {
assert.deepEqual(
buildOrderedWorkTabIds(
['session-2', 'session-1', 'session-2', 'session-1'],
['session-1', 'session-2', 'session-3', 'session-3'],
),
['session-2', 'session-1', 'session-3'],
);
});
test('work tab order reorders with newly materialized tabs', () => {
assert.deepEqual(
reorderWorkTabIds(
['session-1', 'session-2', 'session-3'],
['session-1', 'session-2', 'session-3'],
'session-1',
'session-3',
'after',
),
['session-2', 'session-3', 'session-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
assert.equal(isRootPageTabId('session-1'), false);
});
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
const logViewIds = new Set(['log-1']);
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
for (const activeTabId of orderedTabs) {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), true);
}
});
test('shared host tree recognizes active log view before tab ordering catches up', () => {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: 'log-1',
logViewIds: new Set(['log-1']),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test('shared host tree stays hidden for native plugin view tabs', () => {
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: pluginTabId,
orderedTabs: [pluginTabId],
sessionIds: new Set(),
workspaceIds: new Set(),
}), false);
});
test('terminal content surface is limited to sessions and workspaces', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
});
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'session-2',
root: { id: 'pane-2', type: 'pane', sessionId: 'session-2' },
}] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree falls back to the first workspace session when focused session is missing', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree fallback prefers workspace tree order over sessions array order', () => {
const sessions = [
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, hostTheme.id);
});
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
test('follow-app host tree applies custom accent onto the published base theme', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'custom',
currentTerminalTheme: currentTheme,
customAccent: '0 100% 50%',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
assert.notEqual(resolved, currentTheme);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: null,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map(),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});

View File

@@ -0,0 +1,190 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { isPluginViewTabId } from '../state/pluginViewTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
function uniqueTabIds(tabIds: readonly string[]): string[] {
const seen = new Set<string>();
const uniqueIds: string[] = [];
for (const tabId of tabIds) {
if (!tabId || seen.has(tabId)) continue;
seen.add(tabId);
uniqueIds.push(tabId);
}
return uniqueIds;
}
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
/**
* Host edit from overlays (Quick Switcher): use the terminal work-surface
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
*/
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const uniqueAllTabIds = uniqueTabIds(allTabIds);
const allTabIdSet = new Set(uniqueAllTabIds);
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
const orderedIdSet = new Set(orderedIds);
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function reorderWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
): string[] {
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
currentOrder.splice(draggedIndex, 1);
let nextTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
nextTargetIndex -= 1;
}
if (position === 'after') {
nextTargetIndex += 1;
}
currentOrder.splice(nextTargetIndex, 0, draggedId);
return currentOrder;
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds = new Set(),
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
if (!enabled) return false;
if (isRootPageTabId(activeTabId)) return false;
if (isPluginViewTabId(activeTabId)) return false;
return orderedTabs.includes(activeTabId)
|| isEditorTabId(activeTabId)
|| logViewIds.has(activeTabId)
|| sessionIds.has(activeTabId)
|| workspaceIds.has(activeTabId);
}
export function isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}: {
activeTabId: string;
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkspaceTargetSession(
workspace: Workspace,
sessions: readonly TerminalSession[],
): TerminalSession | undefined {
const sessionById = new Map(sessions.map((session) => [session.id, session]));
return resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
}
export function resolveWorkspaceTargetSessionFromMap(
workspace: Workspace,
sessionById: ReadonlyMap<string, TerminalSession>,
): TerminalSession | undefined {
const orderedSessionIds = collectSessionIds(workspace.root);
const workspaceSessionIdSet = new Set(orderedSessionIds);
const focusedSession = workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: undefined;
const validFocusedSession = focusedSession && workspaceSessionIdSet.has(focusedSession.id)
? focusedSession
: undefined;
if (validFocusedSession) return validFocusedSession;
for (const sessionId of orderedSessionIds) {
const session = sessionById.get(sessionId);
if (session) return session;
}
return undefined;
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}: {
activeTabId: string;
editorTabs: readonly EditorTabChrome[];
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): string | null {
if (isEditorTabId(activeTabId)) {
const editorId = fromEditorTabId(activeTabId);
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) return activeSession.hostId ?? null;
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
return targetSession?.hostId ?? null;
}
export function resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}: {
activeHostId: string | null;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}