[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
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:
160
application/app/AppActiveTabChrome.tsx
Normal file
160
application/app/AppActiveTabChrome.tsx
Normal 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;
|
||||
}
|
||||
57
application/app/AppFollowTerminalTheme.test.ts
Normal file
57
application/app/AppFollowTerminalTheme.test.ts
Normal 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\}/);
|
||||
});
|
||||
48
application/app/AppHandlers.portForwarding.test.ts
Normal file
48
application/app/AppHandlers.portForwarding.test.ts
Normal 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 });
|
||||
});
|
||||
237
application/app/AppHandlers.test.ts
Normal file
237
application/app/AppHandlers.test.ts
Normal 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);
|
||||
});
|
||||
1198
application/app/AppHandlers.ts
Normal file
1198
application/app/AppHandlers.ts
Normal file
File diff suppressed because it is too large
Load Diff
112
application/app/AppHostEditorLayer.test.ts
Normal file
112
application/app/AppHostEditorLayer.test.ts
Normal 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/);
|
||||
});
|
||||
207
application/app/AppHostEditorLayer.tsx
Normal file
207
application/app/AppHostEditorLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
application/app/AppHostEditorSurface.tsx
Normal file
51
application/app/AppHostEditorSurface.tsx
Normal 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;
|
||||
60
application/app/AppHostTreeLayer.test.ts
Normal file
60
application/app/AppHostTreeLayer.test.ts
Normal 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\)/);
|
||||
});
|
||||
|
||||
183
application/app/AppHostTreeLayer.tsx
Normal file
183
application/app/AppHostTreeLayer.tsx
Normal 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';
|
||||
13
application/app/AppLocalState.tsx
Normal file
13
application/app/AppLocalState.tsx
Normal 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}</>;
|
||||
}
|
||||
78
application/app/AppMounts.test.ts
Normal file
78
application/app/AppMounts.test.ts
Normal 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\)/);
|
||||
});
|
||||
212
application/app/AppMounts.tsx
Normal file
212
application/app/AppMounts.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
application/app/AppPluginKeybindingHost.tsx
Normal file
40
application/app/AppPluginKeybindingHost.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
233
application/app/AppShell.architecture.test.ts
Normal file
233
application/app/AppShell.architecture.test.ts
Normal 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\)/);
|
||||
});
|
||||
68
application/app/AppShell.tsx
Normal file
68
application/app/AppShell.tsx
Normal 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';
|
||||
18
application/app/AppSideEffects.appLock.test.ts
Normal file
18
application/app/AppSideEffects.appLock.test.ts
Normal 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\)/);
|
||||
});
|
||||
15
application/app/AppSideEffects.paneMagnification.test.ts
Normal file
15
application/app/AppSideEffects.paneMagnification.test.ts
Normal 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\)/);
|
||||
});
|
||||
30
application/app/AppSideEffects.snippetsDelete.test.ts
Normal file
30
application/app/AppSideEffects.snippetsDelete.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
2017
application/app/AppSideEffects.tsx
Normal file
2017
application/app/AppSideEffects.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
application/app/AppView.activeTabIsolation.test.ts
Normal file
23
application/app/AppView.activeTabIsolation.test.ts
Normal 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
1064
application/app/AppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
19
application/app/AppView.workspaceHostAppend.test.ts
Normal file
19
application/app/AppView.workspaceHostAppend.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
290
application/app/activeChromeTheme.test.ts
Normal file
290
application/app/activeChromeTheme.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
125
application/app/activeChromeTheme.ts
Normal file
125
application/app/activeChromeTheme.ts
Normal 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;
|
||||
}
|
||||
64
application/app/appHandlersBridge.ts
Normal file
64
application/app/appHandlersBridge.ts
Normal 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);
|
||||
}
|
||||
116
application/app/appLocalUiStore.ts
Normal file
116
application/app/appLocalUiStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
120
application/app/appShellPropsStore.ts
Normal file
120
application/app/appShellPropsStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
57
application/app/appViewDomains.test.ts
Normal file
57
application/app/appViewDomains.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
41
application/app/appViewDomains.ts
Normal file
41
application/app/appViewDomains.ts
Normal 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;
|
||||
}
|
||||
119
application/app/dedicatedResumeProgress.test.ts
Normal file
119
application/app/dedicatedResumeProgress.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
100
application/app/dedicatedResumeProgress.ts
Normal file
100
application/app/dedicatedResumeProgress.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
210
application/app/hosts/ChromeHost.tsx
Normal file
210
application/app/hosts/ChromeHost.tsx
Normal 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;
|
||||
}
|
||||
107
application/app/hosts/DialogsHost.tsx
Normal file
107
application/app/hosts/DialogsHost.tsx
Normal 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;
|
||||
}
|
||||
283
application/app/hosts/TerminalHost.tsx
Normal file
283
application/app/hosts/TerminalHost.tsx
Normal 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;
|
||||
}
|
||||
105
application/app/hosts/VaultHost.tsx
Normal file
105
application/app/hosts/VaultHost.tsx
Normal 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;
|
||||
}
|
||||
9
application/app/hosts/mountsDomain.ts
Normal file
9
application/app/hosts/mountsDomain.ts
Normal 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,
|
||||
});
|
||||
97
application/app/keyboardInteractiveScope.test.ts
Normal file
97
application/app/keyboardInteractiveScope.test.ts
Normal 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" }]);
|
||||
});
|
||||
53
application/app/publishers/AppLockRuntimePublisher.tsx
Normal file
53
application/app/publishers/AppLockRuntimePublisher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
application/app/publishers/SessionPublisher.tsx
Normal file
226
application/app/publishers/SessionPublisher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
application/app/publishers/SettingsPublisher.tsx
Normal file
43
application/app/publishers/SettingsPublisher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
application/app/publishers/VaultPublisher.tsx
Normal file
200
application/app/publishers/VaultPublisher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
application/app/strictModeIdempotency.test.ts
Normal file
275
application/app/strictModeIdempotency.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
104
application/app/tabShortcutTargets.test.ts
Normal file
104
application/app/tabShortcutTargets.test.ts
Normal 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);
|
||||
});
|
||||
33
application/app/tabShortcutTargets.ts
Normal file
33
application/app/tabShortcutTargets.ts
Normal 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;
|
||||
}
|
||||
52
application/app/themeRuntimeBridge.ts
Normal file
52
application/app/themeRuntimeBridge.ts
Normal 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);
|
||||
}
|
||||
30
application/app/topTabsChromeTheme.test.ts
Normal file
30
application/app/topTabsChromeTheme.test.ts
Normal 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'\)/);
|
||||
});
|
||||
115
application/app/topTabsChromeTheme.ts
Normal file
115
application/app/topTabsChromeTheme.ts
Normal 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;
|
||||
}
|
||||
579
application/app/useAppStartupEffects.ts
Normal file
579
application/app/useAppStartupEffects.ts
Normal 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]);
|
||||
|
||||
|
||||
}
|
||||
28
application/app/useAppThemeStyle.ts
Normal file
28
application/app/useAppThemeStyle.ts
Normal 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]);
|
||||
}
|
||||
310
application/app/workTabSurface.test.ts
Normal file
310
application/app/workTabSurface.test.ts
Normal 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);
|
||||
});
|
||||
190
application/app/workTabSurface.ts
Normal file
190
application/app/workTabSurface.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user