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

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

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
const chromeThemeSource = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
test("active tab changes notify chrome theme before react subscribers", () => {
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string(?:, options\?: \w+)?\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
"sync chrome theme listeners must run before deferred react notify",
);
assert.match(source, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
assert.match(source, /isActiveChromeThemeResolvable/);
assert.match(source, /clearTopTabsChromeThemeVars/);
});
test("tab chrome notify defers apply via rAF and short-circuits on fingerprint", () => {
assert.match(source, /requestAnimationFrame/);
assert.match(source, /themeFingerprint/);
assert.match(source, /export function applyChromeThemeForTab/);
assert.match(source, /export function notifyActiveChromeThemeForTab/);
// Tab path must not use view transitions.
assert.doesNotMatch(source, /mode:\s*['"]view['"]/);
});
test("syncActiveChromeTheme applies terminal chrome with instant mode", () => {
assert.match(chromeThemeSource, /mode:\s*['"]instant['"]/);
assert.match(chromeThemeSource, /nextFingerprint === appliedFingerprint/);
});

View File

@@ -0,0 +1,80 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTabChrome } from './editorTabStore';
import type { LogView } from './logViewState';
import { syncActiveChromeTheme, themeFingerprint } from './useActiveChromeTheme';
export type ActiveChromeThemeDeps = {
accentMode: 'theme' | 'custom';
applyAppTheme: () => void;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTabChrome[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
let depsRef: ActiveChromeThemeDeps | null = null;
let pendingRafId: number | null = null;
let pendingActiveTabId: string | null = null;
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
depsRef = deps;
}
/**
* Apply chrome theme for a tab. Short-circuits when the resolved theme
* fingerprint matches the already-applied chrome fingerprint so rapid tab
* clicks do not force style work. Tab switches always use instant mode via
* syncActiveChromeTheme (no view transitions).
*/
export function applyChromeThemeForTab(activeTabId: string): void {
if (!depsRef || typeof document === 'undefined') return;
if (activeTabId === 'vault' || activeTabId === 'sftp') {
clearTopTabsChromeThemeVars();
}
// Non-terminal tabs: React chrome effect clears overlay theme. Do not force
// a full style reset here on every vault/sftp click.
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
// Fingerprint short-circuit is also inside syncActiveChromeTheme; check here
// so we avoid even building transition work when the theme is unchanged.
if (activeTheme) {
const nextFp = themeFingerprint(activeTheme);
const applied = document.documentElement.dataset.activeChromeTheme ?? null;
if (nextFp === applied) return;
}
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
}
/**
* Schedule chrome theme apply on the next animation frame so click handlers
* and React commit are not blocked by :root CSS rewrites.
*/
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
pendingActiveTabId = activeTabId;
if (typeof document === 'undefined') {
applyChromeThemeForTab(activeTabId);
return;
}
if (pendingRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0) as unknown as number;
pendingRafId = schedule(() => {
pendingRafId = null;
const tabId = pendingActiveTabId;
pendingActiveTabId = null;
if (tabId != null) applyChromeThemeForTab(tabId);
});
}
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);

View File

@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('active tab store remembers the previous tab for restore-on-close', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
const marker = `prev-tab-${Date.now()}`;
const first = `${marker}-a`;
const second = `${marker}-b`;
const third = `${marker}-c`;
const before = activeTabStore.getActiveTabId();
try {
activeTabStore.setActiveTabId(first);
activeTabStore.setActiveTabId(second);
assert.equal(activeTabStore.getPreviousActiveTabId(), first);
activeTabStore.setActiveTabId(third);
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
activeTabStore.setActiveTabId(first, { recordPrevious: false });
assert.equal(activeTabStore.getActiveTabId(), first);
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
} finally {
activeTabStore.setActiveTabId(before, { recordPrevious: false });
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
}
});
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
});
test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});
test('active tab changes do not start terminal layout suppression', async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
await new Promise((resolve) => setTimeout(resolve, 0));
let activeNotifyCount = 0;
let suppressNotifyCount = 0;
const unsubscribeActiveTab = activeTabStore.subscribe(() => {
activeNotifyCount += 1;
});
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
suppressNotifyCount += 1;
});
try {
activeTabStore.setActiveTabId(`no-suppress-test-${Date.now()}`);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(activeNotifyCount, 1);
assert.equal(suppressNotifyCount, 0);
assert.equal(terminalLayoutSuppressStore.getActive(), false);
} finally {
unsubscribeActiveTab();
unsubscribeSuppress();
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
}
});

View File

@@ -0,0 +1,114 @@
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
/** Strip the "editor:" prefix to recover the internal editorTab id. */
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
type SetActiveTabOptions = {
/** When false, keep the previous-tab pointer unchanged (used for close restore). */
recordPrevious?: boolean;
};
class ActiveTabStore {
private activeTabId: string = 'vault';
private previousActiveTabId: string | null = null;
private listeners = new Set<Listener>();
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
getPreviousActiveTabId = () => this.previousActiveTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
this.notifyRafId = schedule(() => {
this.notifyRafId = null;
this.listeners.forEach((listener) => listener());
});
};
setActiveTabId = (id: string, options?: SetActiveTabOptions) => {
if (this.activeTabId !== id) {
if (options?.recordPrevious !== false) {
this.previousActiveTabId = this.activeTabId;
}
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
}
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
subscribeSync = (listener: SyncListener) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
}
export const activeTabStore = new ActiveTabStore();
// Hook to read active tab ID - only re-renders when activeTabId changes
export const useActiveTabId = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Stable snapshot functions - defined once outside components
const getIsVaultActive = () => activeTabStore.getActiveTabId() === 'vault';
const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
// Check if vault is active
export const useIsVaultActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsVaultActive,
getIsVaultActive,
);
};
// Check if sftp is active
export const useIsSftpActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsSftpActive,
getIsSftpActive,
);
};
// Check if a specific editor tab is currently active
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -0,0 +1,478 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneStaleSessionPanelViews,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
selectDraftForAgentSwitch,
setDraftView,
setSessionView,
updateDraftForScope,
draftsByScopeEqualIgnoringComposerText,
draftsByScopeEqualIgnoringAllComposerText,
} from "./aiDraftState.ts";
test("draftsByScopeEqualIgnoringComposerText ignores typing in the active scope", () => {
const base = createEmptyDraft("catty");
const prev = { "terminal:1": { ...base, text: "" } };
const next = { "terminal:1": { ...base, text: "你好", updatedAt: base.updatedAt + 1 } };
assert.equal(draftsByScopeEqualIgnoringComposerText(prev, next, "terminal:1"), true);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{ "terminal:1": { ...base, text: "你好", attachments: [{ id: "a" } as never] } },
"terminal:1",
),
false,
);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{ "terminal:1": prev["terminal:1"], "workspace:2": base },
"terminal:1",
),
true,
);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{
"terminal:1": prev["terminal:1"],
"workspace:2": { ...base, attachments: [{ id: "a" } as never] },
},
"terminal:1",
),
false,
);
});
test("first composer draft is treated as text-only identity churn", () => {
const empty = createEmptyDraft("catty");
const created = { ...empty, text: "你" };
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": empty }), false);
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": created }), true);
assert.equal(
draftsByScopeEqualIgnoringComposerText({}, { "terminal:1": created }, "terminal:1"),
true,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{ "terminal:1": empty },
{ "terminal:1": created },
),
true,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{ "terminal:1": created },
{ "terminal:1": empty },
),
false,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{},
{ "terminal:1": { ...created, attachments: [{ id: "a" } as never] } },
),
false,
);
});
test("draftsByScopeEqualIgnoringAllComposerText ignores typing in every scope", () => {
const base = createEmptyDraft("catty");
const prev = {
"terminal:1": { ...base, text: "a" },
"terminal:2": { ...base, text: "b", attachments: [] },
};
const next = {
"terminal:1": { ...prev["terminal:1"], text: "aa", updatedAt: base.updatedAt + 1 },
"terminal:2": { ...prev["terminal:2"], text: "bb", updatedAt: base.updatedAt + 2 },
};
assert.equal(draftsByScopeEqualIgnoringAllComposerText(prev, next), true);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(prev, {
...next,
"terminal:2": { ...next["terminal:2"], attachments: [{ id: "a" } as never] },
}),
false,
);
});
test("updateDraftForScope keeps the original map when the updater is a no-op", () => {
const draft = createEmptyDraft("catty");
const prev = { "terminal:1": draft };
const next = updateDraftForScope(prev, "terminal:1", "catty", (current) => current);
assert.equal(next, prev);
});
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
assert.equal(draft.agentId, "agent-alpha");
assert.equal(draft.text, "");
assert.deepEqual(draft.attachments, []);
assert.deepEqual(draft.selectedUserSkillSlugs, []);
assert.equal(typeof draft.updatedAt, "number");
});
test("resolvePanelView defaults to draft when no explicit view exists", () => {
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
});
test("setDraftView records draft mode", () => {
assert.deepEqual(setDraftView({}, "terminal:123"), {
"terminal:123": { mode: "draft" },
});
});
test("activateDraftView clears the terminal scope's active session owner", () => {
const activeSessionIdMap = {
"terminal:123": "session-123",
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "session", sessionId: "session-123" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.deepEqual(next.activeSessionIdMap, {
"workspace:abc": "session-workspace",
});
assert.deepEqual(next.panelViewByScope, {
"terminal:123": { mode: "draft" },
"workspace:abc": panelViewByScope["workspace:abc"],
});
});
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
const activeSessionIdMap = {
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "draft" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("setSessionView records target session id", () => {
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
"workspace:abc": { mode: "session", sessionId: "session-123" },
});
});
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "deleted-session" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.deepEqual(next, {
"terminal:1": { mode: "draft" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
});
});
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-keep" },
"terminal:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.equal(next, panelViewByScope);
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-123" },
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
assert.deepEqual(next.draftsByScope, {
"workspace:2": draftsByScope["workspace:2"],
});
assert.deepEqual(next.panelViewByScope, {
"workspace:2": panelViewByScope["workspace:2"],
});
});
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = updateDraftForScope(
draftsByScope,
"terminal:1",
"agent-alpha",
(draft) => ({
...draft,
text: "hello world",
}),
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "hello world");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-alpha",
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-beta",
);
assert.equal(next, draftsByScope);
});
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "keep me only if I was already drafting",
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "keep me only if I was already drafting");
assert.deepEqual(next.attachments, currentDraft.attachments);
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
const currentDraft = createEmptyDraft("agent-alpha");
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "");
assert.deepEqual(next.attachments, []);
assert.deepEqual(next.selectedUserSkillSlugs, []);
});
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "unfinished prompt",
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "unfinished prompt");
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("draft mutation version increments on every mutation for the same scope", () => {
const scopeKey = "terminal:1";
const initialVersion = getDraftMutationVersionState({}, scopeKey);
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
assert.equal(initialVersion, 0);
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
});
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
const scopeKey = "terminal:1";
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
assert.equal(initialGeneration, 0);
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-1" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
const activeSessionIdMap = {
"terminal:closed": "session-closed",
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
const activeSessionIdMap = {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});

View File

@@ -0,0 +1,363 @@
import type {
AIDraft,
AIPanelView,
} from '../../infrastructure/ai/types';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
type DraftMutationVersionByScope = Record<string, number>;
type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
function isComposerOnlyDraft(draft: AIDraft | undefined): boolean {
return Boolean(
draft
&& draft.attachments.length === 0
&& draft.selectedUserSkillSlugs.length === 0,
);
}
/**
* First keystroke creates a missing → composer-only draft. That is still just
* typing. Clearing a draft (`right` missing or emptied) is a real lifecycle
* change so New Chat / send can reset the uncontrolled composer.
*/
function draftsEquivalentIgnoringComposerText(
left: AIDraft | undefined,
right: AIDraft | undefined,
): boolean {
if (left === right) return true;
if (!left) return Boolean(isComposerOnlyDraft(right) && right.text.trim().length > 0);
if (!right) return false;
if (left.agentId !== right.agentId) return false;
if (left.attachments !== right.attachments) return false;
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
const leftEmpty = left.text.trim().length === 0;
const rightEmpty = right.text.trim().length === 0;
if (leftEmpty !== rightEmpty) return leftEmpty;
return true;
}
/** True when every scope is unchanged except composer text/updatedAt. */
export function draftsByScopeEqualIgnoringAllComposerText(
prev: DraftsByScope,
next: DraftsByScope,
): boolean {
if (prev === next) return true;
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
if (!draftsEquivalentIgnoringComposerText(prev[key], next[key])) return false;
}
return true;
}
/** Typing only changes text/updatedAt. Sibling empty-draft creates stay ignored. */
export function draftsByScopeEqualIgnoringComposerText(
prev: DraftsByScope,
next: DraftsByScope,
scopeKey: string,
): boolean {
if (prev === next) return true;
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
const left = prev[key];
const right = next[key];
if (key !== scopeKey && !left && isComposerOnlyDraft(right)) continue;
if (!draftsEquivalentIgnoringComposerText(left, right)) return false;
}
return true;
}
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
agentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
};
}
export function getDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): number {
return versionsByScope[scopeKey] ?? 0;
}
export function bumpDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): DraftMutationVersionByScope {
return {
...versionsByScope,
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
};
}
export function getDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): number {
return generationsByScope[scopeKey] ?? 0;
}
export function bumpDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): DraftUploadGenerationByScope {
return {
...generationsByScope,
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): AIPanelView {
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
}
export function setDraftView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): PanelViewByScope {
const currentPanelView = panelViewByScope[scopeKey];
if (currentPanelView?.mode === 'draft') {
return panelViewByScope;
}
return {
...panelViewByScope,
[scopeKey]: DEFAULT_PANEL_VIEW,
};
}
export function activateDraftView(
activeSessionIdMap: ActiveSessionIdMap,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
activeSessionIdMap: ActiveSessionIdMap;
panelViewByScope: PanelViewByScope;
} {
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
if (!hasActiveSession) {
return {
activeSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
const nextActiveSessionIdMap = { ...activeSessionIdMap };
delete nextActiveSessionIdMap[scopeKey];
return {
activeSessionIdMap: nextActiveSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
export function setSessionView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
sessionId: string,
): PanelViewByScope {
return {
...panelViewByScope,
[scopeKey]: { mode: 'session', sessionId },
};
}
export function pruneStaleSessionPanelViews(
panelViewByScope: PanelViewByScope,
validSessionIds: Set<string>,
): PanelViewByScope {
let next = panelViewByScope;
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
continue;
}
const updated = setDraftView(next, scopeKey);
if (updated !== next) {
next = updated;
}
}
return next;
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
if (nextDraft === currentDraft && draftsByScope[scopeKey] === currentDraft) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: nextDraft,
};
}
export function ensureDraftForScopeState(
draftsByScope: DraftsByScope,
scopeKey: string,
agentId: string,
): DraftsByScope {
if (draftsByScope[scopeKey]) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: createEmptyDraft(agentId),
};
}
export function selectDraftForAgentSwitch(
currentDraft: AIDraft | null | undefined,
agentId: string,
startFresh: boolean,
): AIDraft {
const hasPendingDraftContent = Boolean(
currentDraft
&& (
currentDraft.text.length > 0
|| currentDraft.attachments.length > 0
|| currentDraft.selectedUserSkillSlugs.length > 0
),
);
if (startFresh && !hasPendingDraftContent) {
return createEmptyDraft(agentId);
}
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
return {
...baseDraft,
agentId,
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
if (!hasDraft && !hasPanelView) {
return {
draftsByScope,
panelViewByScope,
};
}
return {
draftsByScope: hasDraft
? (() => {
const nextDrafts = { ...draftsByScope };
delete nextDrafts[scopeKey];
return nextDrafts;
})()
: draftsByScope,
panelViewByScope: hasPanelView
? (() => {
const nextPanelViews = { ...panelViewByScope };
delete nextPanelViews[scopeKey];
return nextPanelViews;
})()
: panelViewByScope,
};
}
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
if (!scopeKey.startsWith('terminal:')) return false;
const targetId = scopeKey.slice('terminal:'.length);
if (!targetId) return false;
return !activeTerminalTargetIds.has(targetId);
}
export function pruneTerminalScopeState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneTerminalTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextTerminalScopeState = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
activeTerminalTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextTerminalScopeState.draftsByScope,
panelViewByScope: nextTerminalScopeState.panelViewByScope,
};
}

View File

@@ -0,0 +1,85 @@
import type React from 'react';
import {
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
try {
return localStorageAdapter.readString(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}

View File

@@ -0,0 +1,39 @@
export function removeProviderReferences(
removedProviderId: string,
agentProviderMap: Record<string, string>,
agentModelMap: Record<string, string>,
): {
agentProviderMap: Record<string, string>;
agentModelMap: Record<string, string>;
providerMapChanged: boolean;
modelMapChanged: boolean;
} {
let providerMapChanged = false;
let modelMapChanged = false;
const orphanedAgents = new Set<string>();
const nextAgentProviderMap: Record<string, string> = {};
for (const [agentId, providerId] of Object.entries(agentProviderMap)) {
if (providerId === removedProviderId) {
providerMapChanged = true;
orphanedAgents.add(agentId);
} else {
nextAgentProviderMap[agentId] = providerId;
}
}
const nextAgentModelMap: Record<string, string> = { ...agentModelMap };
for (const agentId of orphanedAgents) {
if (agentId in nextAgentModelMap) {
delete nextAgentModelMap[agentId];
modelMapChanged = true;
}
}
return {
agentProviderMap: providerMapChanged ? nextAgentProviderMap : agentProviderMap,
agentModelMap: modelMapChanged ? nextAgentModelMap : agentModelMap,
providerMapChanged,
modelMapChanged,
};
}

View File

@@ -0,0 +1,182 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
id,
title: id,
agentId: "catty",
scope,
messages: [],
externalSessionId,
createdAt: 1,
updatedAt: 1,
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}, "ext-1"),
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-2"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-3"),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-restorable",
"terminal-local",
"workspace-closed",
]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves inactive workspace and local terminal history after restart", () => {
const sessions = [
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-local"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-workspace"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-local",
"workspace-closed",
]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses external agent continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",
hostIds: ["host-1"],
}, "ext-resumed");
const trulyOrphaned = createSession("terminal-stale", {
type: "terminal",
targetId: "terminal-closed-C",
hostIds: ["host-2"],
}, "ext-stale");
const sessions = [resumedElsewhere, trulyOrphaned];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["terminal-open-B"]),
new Set(["terminal-restorable"]),
);
// Only the one not being displayed anywhere should show up as orphaned.
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
// The resumed session must retain its externalSessionId.
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
});

View File

@@ -0,0 +1,124 @@
import type {
AIDraft,
AIPanelView,
AISession,
} from "../../infrastructure/ai/types";
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
activeTargetIds: Set<string>,
): boolean {
const separatorIndex = scopeKey.indexOf(":");
if (separatorIndex === -1) return false;
const scopeType = scopeKey.slice(0, separatorIndex);
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return false;
return !activeTargetIds.has(targetId);
}
export function pruneInactiveScopedState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
/**
* Session ids currently displayed by any live scope. A session whose
* `scope.targetId` is inactive but whose id is still in use somewhere
* (e.g. resumed from history into a different terminal) must not be
* treated as orphaned — deleting it outright would break the chat the
* user is actively continuing.
*/
activeSessionIds: Set<string> = new Set(),
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.filter((session) => !activeSessionIds.has(session.id))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
return {
sessions,
orphanedSessionIds,
};
}
return {
sessions,
orphanedSessionIds,
};
}

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEmptyDraft } from './aiDraftState.ts';
import { aiSessionsStore } from './aiSessionsStore.ts';
test('AI sessions store does not notify listeners when only composer text changes', () => {
const previous = aiSessionsStore.getSnapshot();
const draft = createEmptyDraft('catty');
const base = {
sessions: Object.freeze([]),
activeSessionIdMap: Object.freeze({}),
draftsByScope: { 'terminal:1': draft },
panelViewByScope: {},
};
try {
aiSessionsStore.setSnapshot(base);
let notified = 0;
const unsubscribe = aiSessionsStore.subscribe(() => {
notified += 1;
});
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: 'hello', updatedAt: draft.updatedAt + 1 },
},
});
assert.equal(notified, 0);
assert.equal(aiSessionsStore.getSnapshot().draftsByScope['terminal:1']?.text, 'hello');
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {},
});
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: '你好' },
},
});
assert.equal(notified, 0);
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: '' },
},
});
assert.equal(notified, 1);
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: 'hello', attachments: [{ id: 'a' } as never] },
},
});
assert.equal(notified, 1);
unsubscribe();
} finally {
aiSessionsStore.setSnapshot(previous);
}
});

View File

@@ -0,0 +1,90 @@
import { useSyncExternalStore } from 'react';
import type { AISession } from '../../infrastructure/ai/types';
import { draftsByScopeEqualIgnoringAllComposerText } from './aiDraftState';
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
type Listener = () => void;
export type AISessionsSnapshot = {
sessions: readonly AISession[];
activeSessionIdMap: Readonly<Record<string, string | null>>;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
};
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
activeSessionIdMap: EMPTY_MAP,
draftsByScope: EMPTY_DRAFTS,
panelViewByScope: EMPTY_PANEL,
});
/**
* Hot AI chat state — streaming message updates must not invalidate
* slow AI config Context consumers (providers, permissions, etc.).
*/
class AISessionsStore {
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AISessionsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AISessionsSnapshot): void {
if (
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.draftsByScope === next.draftsByScope
&& this.snapshot.panelViewByScope === next.panelViewByScope
) {
return;
}
const textOnlyDraftChange =
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.panelViewByScope === next.panelViewByScope
&& draftsByScopeEqualIgnoringAllComposerText(
this.snapshot.draftsByScope,
next.draftsByScope,
);
this.snapshot = next;
if (textOnlyDraftChange) return;
for (const listener of this.listeners) {
listener();
}
}
}
export const aiSessionsStore = new AISessionsStore();
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
aiSessionsStore.setSnapshot(snapshot);
}
export function getAISessionsSnapshot(): AISessionsSnapshot {
return aiSessionsStore.getSnapshot();
}
export function subscribeAISessions(listener: Listener): () => void {
return aiSessionsStore.subscribe(listener);
}
export function useAISessionsStore(): AISessionsSnapshot {
return useSyncExternalStore(
subscribeAISessions,
getAISessionsSnapshot,
getAISessionsSnapshot,
);
}

View File

@@ -0,0 +1,20 @@
/**
* Same-window AI-state-changed event plumbing.
*
* `localStorage` writes only emit `storage` events in *other* windows; the
* window doing the write never gets notified. That's a problem for code
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
* apply): without a manual nudge, mounted components keep showing stale
* AI state until reload.
*
* Both the dispatcher and `useAIState`'s listener live here so non-React
* call sites (sync, IPC handlers, etc.) can fire the event without
* pulling in the hook.
*/
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
export function emitAIStateChanged(key: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}

View File

@@ -0,0 +1,513 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
cleanupClosedTerminalSessions,
cleanupDeletedAIChatSessions,
cleanupSdkAgentSessions,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
const sdkCleanups: string[] = [];
const outputCleanups: string[] = [];
const terminalOutputCleanups: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
aiSdkAgentCleanup: async (chatSessionId: string) => {
sdkCleanups.push(chatSessionId);
return { ok: true };
},
deleteChatToolOutputsTemp: async (chatSessionId: string) => {
outputCleanups.push(chatSessionId);
return { deletedCount: 1 };
},
deleteTerminalToolOutputsEverywhereTemp: async (terminalSessionId: string) => {
terminalOutputCleanups.push(terminalSessionId);
return { deletedCount: 1 };
},
},
},
});
try {
cleanupSdkAgentSessions(['history-kept']);
cleanupDeletedAIChatSessions(['history-deleted']);
cleanupClosedTerminalSessions(['terminal-closed', 'terminal-closed']);
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(sdkCleanups, ['history-kept', 'history-deleted']);
assert.deepEqual(outputCleanups, ['history-deleted']);
assert.deepEqual(terminalOutputCleanups, ['terminal-closed']);
} finally {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: messages as never,
createdAt: updatedAt,
updatedAt,
} as never;
}
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
const messages = () => [{
id: 'm',
role: 'assistant' as const,
content: 'hello',
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}];
const sessions = [
makeSession('newest', 3, messages()),
makeSession('older', 2, messages()),
makeSession('oldest', 1, messages()),
];
const hasCiphertext = (result: { sessions: AISession[] }) =>
result.sessions.some(s => s.messages.some(m =>
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
// Removing replay-only ciphertext from the oldest session fits the budget,
// so every visible chat remains available after restart.
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
assert.deepEqual(
withOldestCiphertextStripped.sessions.map(s => s.id),
['newest', 'older', 'oldest'],
);
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
// Tight budget that even a single session exceeds: ciphertext stripped but
// the visible conversation content survives.
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
assert.equal(hasCiphertext(withCiphertextStripped), false);
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
});
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const oversizedOld = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(300 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 200 * 1024);
});
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const olderVisible = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(100 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 150 * 1024);
});
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const reasoning = (itemId: string) => ({
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{
text: '',
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
}],
});
const session = makeSession('active', 1, [
{
id: 'assistant-old',
role: 'assistant',
content: 'old turn',
timestamp: 1,
providerContinuation: reasoning('rs_old'),
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
},
{
id: 'tool-old',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
},
{
id: 'assistant-new',
role: 'assistant',
content: 'new turn',
timestamp: 3,
providerContinuation: reasoning('rs_new'),
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-new',
role: 'tool',
content: '',
timestamp: 4,
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
},
]);
const result = serializeSessionsForStorage([session], 150 * 1024);
assert.ok(result.json.length <= 150 * 1024);
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
'assistant-old',
'tool-old',
'assistant-new',
'tool-new',
]);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
result.sessions[0].messages[2].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
id: `compacted-${index}`,
role: 'assistant' as const,
content: `compacted turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const newest = {
...makeSession('newest', 2, [
...compactedMessages,
{
id: 'recent',
role: 'assistant',
content: 'recent turn',
timestamp: 18,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
},
}],
},
},
]),
contextCompaction: {
summary: 'The first 17 turns were summarized.',
compactedMessageCount: 17,
},
};
const olderVisible = makeSession('older', 1, [{
id: 'older-visible',
role: 'user',
content: 'x'.repeat(600 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest]);
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
assert.ok(result.json.length <= 2 * 1024 * 1024);
const persistedNewest = result.sessions[0];
for (const message of persistedNewest.messages.slice(0, 17)) {
assert.equal(
message.providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
}
assert.equal(
persistedNewest.messages[17].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'enc'.repeat(100);
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'assistant' as const,
content: `turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const session = {
...makeSession('trimmed', 1, messages),
contextCompaction: {
summary: 'The first 100 messages were summarized.',
compactedMessageCount: 100,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 200);
assert.equal(persisted.messages[0].id, 'message-50');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
assert.equal(
persisted.messages[49].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
persisted.messages[50].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'user' as const,
content: `turn ${index}`,
timestamp: index,
}));
messages[49] = {
id: 'assistant-call',
role: 'assistant',
content: 'Running it.',
timestamp: 49,
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
} as never;
messages[50] = {
id: 'tool-result',
role: 'tool',
content: '',
timestamp: 50,
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
} as never;
const session = {
...makeSession('tool-boundary', 1, messages),
contextCompaction: {
summary: 'The first 10 messages were summarized.',
compactedMessageCount: 10,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 201);
assert.equal(persisted.messages[0].id, 'assistant-call');
assert.equal(persisted.messages[1].id, 'tool-result');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
});
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 350 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
// The retry loop must attempt progressively smaller payloads (here the
// ciphertext stripping at the tighter budget) before reporting failure.
const huge = 'x'.repeat(260 * 1024);
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
const sessions = [makeSession('s', 1, [{
id: 'm',
role: 'user' as const,
content: huge,
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}])];
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 2);
assert.equal(stored, writes[1]);
assert.ok(writes[writes.length - 1].length < writes[0].length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes[0].length > 300 * 1024);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 50 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 6);
assert.ok(stored);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
assert.ok(stored.length <= 50 * 1024);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});

View File

@@ -0,0 +1,513 @@
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
getDraftUploadGenerationState,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
export function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
export const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
}
}
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
getAgentRuntime().clearChatSession(sessionId);
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
}
}
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
const bridge = getAIBridge();
for (const terminalSessionId of new Set(terminalSessionIds)) {
getAgentRuntime().clearTerminalSession(terminalSessionId);
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
writeSessionsForStorage(nextSessionCleanup.sessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Byte budget for the serialized sessions JSON. The localStorage quota is
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
* of KB per turn, so keep the sessions blob well under the quota with
* headroom for the rest of the app's storage keys.
*/
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
// Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages,
MAX_SESSION_MESSAGES,
);
const contextCompaction = s.contextCompaction
? {
...s.contextCompaction,
compactedMessageCount: Math.max(
0,
s.contextCompaction.compactedMessageCount - removedMessageCount,
),
}
: undefined;
return {
...s,
messages: s.messages.slice(removedMessageCount),
...(contextCompaction ? { contextCompaction } : {}),
};
}
return s;
});
}
/**
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
const json = JSON.stringify(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
ciphertextMessages,
};
});
// Preserve the newest session's full continuation whenever it can fit by
// itself. That session is the one the user is most likely continuing now;
// older visible history must not make its next tool turn unreplayable.
const protectNewestContinuation = serialized.length > 0
&& serialized[0].json.length + 2 <= budgetBytes;
// Determine how many sessions can fit using the smallest representation for
// older sessions while reserving the newest session's full representation
// when possible. This also prevents an old, oversized visible chat that must
// be dropped anyway from causing newer replay ciphertext to be stripped.
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* Persist sessions to localStorage with byte-budgeted pruning and retries.
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
export function writeSessionsForStorage(sessions: AISession[]): boolean {
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
// the failed payload was already below the nominal retry budget. Reduce
// materially on each bounded retry so several small sessions can be
// removed before the retry sequence is exhausted.
const reducedBudget = Number.isFinite(previousLength)
? Math.floor(previousLength * 0.75)
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat.
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if (
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
return true;
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
export function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
export function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
export function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppSessionRuntime,
getAppSettingsRuntime,
getAppVaultRuntime,
registerAppSessionRuntime,
registerAppSettingsRuntime,
registerAppVaultRuntime,
subscribeAppSessionRuntime,
subscribeAppSettingsRuntime,
subscribeAppVaultRuntime,
type AppSessionRuntime,
type AppSettingsRuntime,
type AppVaultRuntime,
} from './appRuntimeBridge';
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
test('vault slot exposes the last registered runtime and notifies on change', () => {
let notifications = 0;
const unsubscribe = subscribeAppVaultRuntime(() => {
notifications += 1;
});
const first = vaultRuntime('first');
registerAppVaultRuntime(first);
assert.equal(getAppVaultRuntime(), first);
assert.equal(notifications, 1);
// Re-registering the same identity is what a render with unchanged state
// does; it must not wake subscribers.
registerAppVaultRuntime(first);
assert.equal(notifications, 1);
const second = vaultRuntime('second');
registerAppVaultRuntime(second);
assert.equal(getAppVaultRuntime(), second);
assert.equal(notifications, 2);
unsubscribe();
registerAppVaultRuntime(null);
assert.equal(getAppVaultRuntime(), null);
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
});
test('session slot is independent from the vault slot', () => {
let vaultNotifications = 0;
let sessionNotifications = 0;
const unsubscribeVault = subscribeAppVaultRuntime(() => {
vaultNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
sessionNotifications += 1;
});
const runtime = sessionRuntime('session');
registerAppSessionRuntime(runtime);
assert.equal(getAppSessionRuntime(), runtime);
assert.equal(sessionNotifications, 1);
assert.equal(vaultNotifications, 0);
unsubscribeVault();
unsubscribeSession();
registerAppSessionRuntime(null);
});
test('settings slot is independent from the vault and session slots', () => {
let settingsNotifications = 0;
let otherNotifications = 0;
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
settingsNotifications += 1;
});
const unsubscribeVault = subscribeAppVaultRuntime(() => {
otherNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
otherNotifications += 1;
});
const runtime = settingsRuntime('settings');
registerAppSettingsRuntime(runtime);
assert.equal(getAppSettingsRuntime(), runtime);
assert.equal(settingsNotifications, 1);
assert.equal(otherNotifications, 0);
// A re-render with unchanged state re-registers the same identity.
registerAppSettingsRuntime(runtime);
assert.equal(settingsNotifications, 1);
unsubscribeSettings();
unsubscribeVault();
unsubscribeSession();
registerAppSettingsRuntime(null);
assert.equal(getAppSettingsRuntime(), null);
});

View File

@@ -0,0 +1,181 @@
import { createContext, useContext } from 'react';
import type { useAppLockState } from './useAppLockState';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
/**
* Narrow app-lock projection published on React context. The full runtime
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
* button, AppSideEffects deferral gates) only need these primitives, and
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
*/
export type AppLockChromeValue = {
appLockEnabled: boolean;
locked: boolean;
initialized: boolean;
};
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
appLockEnabled: false,
locked: false,
initialized: false,
};
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
* context.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
* absent. They churn on every note keystroke, session start/exit, and history
* append, and publishing them here would re-render every context consumer for
* data those consumers only ever read at call time. Consumers subscribe to
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
* the full runtime imperatively through `getAppVaultRuntime()`.
*
* `exportData` is omitted for the same reason: its callback identity tracks the
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
*/
export type AppVaultContextValue = Omit<
AppVaultRuntime,
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
>;
type Listener = () => void;
/**
* Holds the live return of one mega hook so the component that owns the hook
* and the components that consume it no longer have to be the same component.
*
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
* two ways:
*
* - through a React context, for render-time consumers (App). Context keeps the
* read synchronous and non-null from the first render, which a store written
* in a layout effect cannot do for a descendant.
* - through this module slot, for imperative callers that need the current
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
* a bridge callback) and for non-descendant subscribers.
*/
class RuntimeSlot<T> {
private value: T | null = null;
private listeners = new Set<Listener>();
get = (): T | null => this.value;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: T | null): void {
if (this.value === next) return;
this.value = next;
for (const listener of this.listeners) {
listener();
}
}
}
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
}
export function getAppVaultRuntime(): AppVaultRuntime | null {
return vaultSlot.get();
}
export function subscribeAppVaultRuntime(listener: Listener): () => void {
return vaultSlot.subscribe(listener);
}
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
sessionSlot.set(runtime);
}
export function getAppSessionRuntime(): AppSessionRuntime | null {
return sessionSlot.get();
}
export function subscribeAppSessionRuntime(listener: Listener): () => void {
return sessionSlot.subscribe(listener);
}
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
settingsSlot.set(runtime);
}
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
return settingsSlot.get();
}
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
appLockSlot.set(runtime);
}
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
return appLockSlot.get();
}
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
return appLockSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
const runtime = useContext(AppVaultRuntimeContext);
if (!runtime) {
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
}
return runtime;
}
/** Read the session runtime owned by `SessionPublisher`. */
export function useAppSessionRuntime(): AppSessionRuntime {
const runtime = useContext(AppSessionRuntimeContext);
if (!runtime) {
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
}
return runtime;
}
/** Read the settings runtime owned by `SettingsPublisher`. */
export function useAppSettingsRuntime(): AppSettingsRuntime {
const runtime = useContext(AppSettingsRuntimeContext);
if (!runtime) {
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
}
return runtime;
}
/**
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
* Falls back to an "unlocked / disabled" default so windows without a
* publisher (isolated mounts, tests) behave as if App Lock is off.
*/
export function useAppLockChrome(): AppLockChromeValue {
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
}

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppearanceChromeSnapshot,
publishAppearanceChromeSnapshot,
subscribeAppearanceChrome,
} from './appearanceChromeStore.ts';
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
const events: string[] = [];
const unsubscribe = subscribeAppearanceChrome(() => {
const snap = getAppearanceChromeSnapshot();
events.push(`${snap.accentMode}:${snap.customAccent}`);
});
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.at(-1), 'custom:#ff0000');
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.length, 1);
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'custom:#00ff00');
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'theme:#00ff00');
unsubscribe();
});

View File

@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
export type AppearanceChromeSnapshot = {
accentMode: 'theme' | 'custom';
customAccent: string;
};
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
accentMode: 'theme',
customAccent: '',
});
/**
* External store for app accent chrome so Terminal leaves can apply custom
* accent without TerminalLayer rebuilding on every color-picker drag tick.
*/
class AppearanceChromeStore {
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppearanceChromeSnapshot): void {
if (
this.snapshot.accentMode === next.accentMode
&& this.snapshot.customAccent === next.customAccent
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const appearanceChromeStore = new AppearanceChromeStore();
export function publishAppearanceChromeSnapshot(
snapshot: AppearanceChromeSnapshot,
): void {
appearanceChromeStore.setSnapshot(snapshot);
}
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
return appearanceChromeStore.getSnapshot();
}
export function subscribeAppearanceChrome(listener: Listener): () => void {
return appearanceChromeStore.subscribe(listener);
}
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
return useSyncExternalStore(
subscribeAppearanceChrome,
getAppearanceChromeSnapshot,
getAppearanceChromeSnapshot,
);
}

View File

@@ -0,0 +1,368 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
import {
hasPersistedAppearanceChanged,
resolveAppearanceStorageEvent,
resolveAppearanceSyncState,
resolveIncomingAppearanceValue,
type AppearanceState,
type AppearanceRenderSnapshot,
type StoredAppearanceValues,
} from "./appearanceSync.ts";
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from "../../infrastructure/config/storageKeys.ts";
const systemLight: AppearanceRenderSnapshot = {
theme: "system",
resolvedTheme: "light",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
let storedTheme: "light" | "dark" | "system" = "dark";
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
storedTheme = systemDark.theme;
}
assert.equal(storedTheme, "dark");
});
test("an explicit theme choice remains a persisted appearance change", () => {
assert.equal(
hasPersistedAppearanceChanged(systemLight, {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
}),
true,
);
});
test("an appearance IPC value wins over stale local storage for the changed key", () => {
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
"system",
"system",
(value): value is "light" | "dark" | "system" => (
value === "light" || value === "dark" || value === "system"
),
),
"dark",
);
// Non-matching keyed IPC must keep the current in-memory value, not storage.
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
"stale-from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"midnight",
);
// Full rehydrate (no incoming) still prefers storage.
assert.equal(
resolveIncomingAppearanceValue(
undefined,
STORAGE_KEY_UI_THEME_DARK,
"from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"from-storage",
);
});
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
const current: AppearanceState = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
// announced on the IPC payload, so the reducer must prefer the payload.
const darkUiSelection = {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
};
const nextDarkUi = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "midnight",
}, darkUiSelection);
assert.equal(nextDarkUi.darkUiThemeId, "github");
assert.equal(nextDarkUi.theme, "dark");
assert.equal(nextDarkUi.lightUiThemeId, "snow");
const lightUiSelection = {
key: STORAGE_KEY_UI_THEME_LIGHT,
value: "flexoki",
};
const nextLightUi = resolveAppearanceSyncState(current, {
...staleStored,
lightUiThemeId: "snow",
}, lightUiSelection);
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
const accentModeSelection = {
key: STORAGE_KEY_ACCENT_MODE,
value: "custom",
};
const nextAccentMode = resolveAppearanceSyncState(current, {
...staleStored,
accentMode: "theme",
}, accentModeSelection);
assert.equal(nextAccentMode.accentMode, "custom");
const customAccentSelection = {
key: STORAGE_KEY_COLOR,
value: "221.2 83.2% 53.3%",
};
const nextCustomAccent = resolveAppearanceSyncState(current, {
...staleStored,
customAccent: "208 100% 50%",
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Stale lower-version accent echoes must not clobber a newer local drag sample.
const afterDrag = {
...current,
customAccent: "199 89% 48%",
customAccentVersion: 2,
};
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
...staleStored,
customAccent: "0 84% 60%",
}, {
key: STORAGE_KEY_COLOR,
value: { color: "0 84% 60%", version: 1 },
});
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
assert.equal(staleAccentEcho.customAccentVersion, 2);
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "github",
});
assert.equal(noIncoming.darkUiThemeId, "github");
});
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
// Storage still holds the pre-change values for the entire sequence.
const initial: AppearanceState = {
theme: "system",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
const staleStored: StoredAppearanceValues = { ...initial };
let next = resolveAppearanceSyncState(initial, staleStored, {
key: STORAGE_KEY_THEME,
value: "dark",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "midnight");
// Later theme-id message must not revert theme back to the stale stored System.
next = resolveAppearanceSyncState(next, staleStored, {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "github");
assert.equal(next.lightUiThemeId, "snow");
assert.equal(next.accentMode, "theme");
});
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
const initialState: AppearanceState = {
theme: systemLight.theme,
lightUiThemeId: systemLight.lightUiThemeId,
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
customAccentVersion: systemLight.customAccentVersion,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
userIntent: idleThemeUserIntent(),
settings: {
terminalThemeId: "netcatty-dark",
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
followAppTerminalTheme: true,
resolvedTheme,
lightUiThemeId: appearance.lightUiThemeId,
darkUiThemeId: appearance.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
},
customThemes: [],
})
);
const persistRender = (
stored: StoredAppearanceValues,
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): StoredAppearanceValues => {
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
return {
theme: current.theme,
lightUiThemeId: current.lightUiThemeId,
darkUiThemeId: current.darkUiThemeId,
accentMode: current.accentMode,
customAccent: current.customAccent,
};
};
let stored: StoredAppearanceValues = { ...initialState };
let main = { ...initialState };
let detachedTerminal = { ...initialState };
const initialMainTerminal = terminalAppearance(main, "light");
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
const settingsDark: AppearanceRenderSnapshot = {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
};
stored = persistRender(stored, systemLight, settingsDark);
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
// A stale peer receives an OS color event before the Dark IPC message. Its
// semantic choice is still System, so it must not write System back.
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
// Main windows run the production IPC reducer. Deliberately pass its lagging
// System storage read so only the ordered Dark payload can win.
main = resolveAppearanceSyncState(main, {
...stored,
theme: "system",
}, darkSelection);
// Detached terminal and tray renderers run the production storage-event
// reducer because they are not direct IPC broadcast targets.
const detachedUpdate = resolveAppearanceStorageEvent(
detachedTerminal,
darkSelection.key,
String(stored.theme),
);
detachedTerminal = detachedUpdate.next;
const finalMainTerminal = terminalAppearance(main, "dark");
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
assert.equal(stored.theme, "dark");
assert.equal(settingsDark.theme, "dark");
assert.equal(main.theme, "dark");
assert.equal(detachedUpdate.handled, true);
assert.equal(detachedTerminal.theme, "dark");
assert.equal(initialMainTerminal.theme.type, "light");
assert.equal(initialDetachedTerminal.theme.type, "light");
assert.equal(finalMainTerminal.theme.type, "dark");
assert.equal(finalDetachedTerminal.theme.type, "dark");
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
});
test("the race guards are wired into the real settings paths", () => {
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
);
assert.match(
stateSource,
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
);
assert.match(
stateSource,
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
);
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
});
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
// — black default vs synced yellow theme flicker (#2757).
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
assert.match(
rehydrateBody,
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
);
assert.match(
rehydrateBody,
/setFollowAppTerminalThemeState/,
"rehydrate must update React follow-app state from storage after cloud sync",
);
});

View File

@@ -0,0 +1,202 @@
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
import {
parseCustomAccentRecord,
shouldApplyCustomAccentRecord,
type CustomAccentRecord,
} from './customAccentSync';
export type AppearanceState = {
theme: "light" | "dark" | "system";
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
/** Sync revision for custom accent; gates stale IPC/storage echoes during color-picker drag. */
customAccentVersion: number;
};
export type AppearanceRenderSnapshot = {
theme: AppearanceState["theme"];
resolvedTheme: "light" | "dark";
} & Omit<AppearanceState, "theme">;
export type AppearanceSyncEvent = {
key: string;
value: unknown;
};
export type StoredAppearanceValues = {
theme: unknown;
lightUiThemeId: unknown;
darkUiThemeId: unknown;
accentMode: unknown;
customAccent: unknown;
};
export type AppearanceStorageEventResolution = {
handled: boolean;
next: AppearanceState;
};
export function hasPersistedAppearanceChanged(
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): boolean {
return previous.theme !== current.theme
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent
|| previous.customAccentVersion !== current.customAccentVersion;
}
export function resolveIncomingAppearanceValue<T>(
incoming: AppearanceSyncEvent | undefined,
key: string,
storedValue: T,
currentValue: T,
isValid: (value: unknown) => value is T,
): T {
if (incoming?.key === key && isValid(incoming.value)) {
return incoming.value;
}
// Keyed IPC updates only trust the announced key. Non-matching fields keep
// the in-memory current value so sequential notifies for one multi-field
// change cannot clobber each other with a still-stale storage read.
// Full rehydrate (no incoming) continues to prefer shared storage.
if (incoming) {
return currentValue;
}
return storedValue;
}
export function resolveAppearanceSyncState(
current: AppearanceState,
stored: StoredAppearanceValues,
incoming?: AppearanceSyncEvent,
): AppearanceState {
const theme = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
stored.theme,
current.theme,
isValidTheme,
);
const lightUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_LIGHT,
stored.lightUiThemeId,
current.lightUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('light', value),
);
const darkUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
stored.darkUiThemeId,
current.darkUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('dark', value),
);
const accentMode = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_ACCENT_MODE,
stored.accentMode,
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const currentAccentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
const incomingAccentRaw = incoming?.key === STORAGE_KEY_COLOR ? incoming.value : undefined;
const resolvedAccentRaw = incomingAccentRaw !== undefined
? incomingAccentRaw
: (incoming ? currentAccentRecord : stored.customAccent);
const resolvedAccentRecord = incomingAccentRaw !== undefined || !incoming
? parseCustomAccentRecord(resolvedAccentRaw)
: currentAccentRecord;
const nextAccentRecord = shouldApplyCustomAccentRecord(currentAccentRecord, resolvedAccentRecord)
? resolvedAccentRecord
: currentAccentRecord;
return {
theme: isValidTheme(theme) ? theme : current.theme,
lightUiThemeId: typeof lightUiThemeId === 'string' && isValidUiThemeId('light', lightUiThemeId)
? lightUiThemeId
: current.lightUiThemeId,
darkUiThemeId: typeof darkUiThemeId === 'string' && isValidUiThemeId('dark', darkUiThemeId)
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: nextAccentRecord.color,
customAccentVersion: nextAccentRecord.version,
};
}
export function resolveAppearanceStorageEvent(
current: AppearanceState,
key: string | null,
newValue: string | null,
): AppearanceStorageEventResolution {
if (key === STORAGE_KEY_THEME) {
return {
handled: true,
next: newValue && isValidTheme(newValue) ? { ...current, theme: newValue } : current,
};
}
if (key === STORAGE_KEY_UI_THEME_LIGHT) {
return {
handled: true,
next: newValue && isValidUiThemeId('light', newValue)
? { ...current, lightUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_UI_THEME_DARK) {
return {
handled: true,
next: newValue && isValidUiThemeId('dark', newValue)
? { ...current, darkUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_ACCENT_MODE) {
return {
handled: true,
next: newValue === 'theme' || newValue === 'custom'
? { ...current, accentMode: newValue }
: current,
};
}
if (key === STORAGE_KEY_COLOR) {
if (newValue == null) {
return { handled: true, next: current };
}
const incoming = parseCustomAccentRecord(newValue);
const currentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
if (!shouldApplyCustomAccentRecord(currentRecord, incoming)) {
return { handled: true, next: current };
}
return {
handled: true,
next: {
...current,
customAccent: incoming.color,
customAccentVersion: incoming.version,
},
};
}
return { handled: false, next: current };
}

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAutoSyncHashDecision } from "./autoSyncHashDecision.ts";
test("remote-applied data is skipped only when the current hash still matches it", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "remote-applied",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"skip-applied",
);
});
test("user edits after remote apply still sync even when they match the old baseline", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "old-local",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"sync",
);
});
test("unchanged data is skipped only when there is no pending remote-apply hash", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "same",
lastSyncedHash: "same",
appliedSkipHash: null,
}),
"unchanged",
);
});

View File

@@ -0,0 +1,16 @@
export type AutoSyncHashDecision = 'skip-applied' | 'unchanged' | 'sync';
export function resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash,
appliedSkipHash,
}: {
currentHash: string;
lastSyncedHash: string;
appliedSkipHash: string | null;
}): AutoSyncHashDecision {
if (appliedSkipHash !== null) {
return currentHash === appliedSkipHash ? 'skip-applied' : 'sync';
}
return currentHash === lastSyncedHash ? 'unchanged' : 'sync';
}

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});

View File

@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}

View File

@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import type { DynamicTabTitleMode } from '../../domain/models';
import {
createCodingCliSessionSignalController,
type CodingCliSessionSignalController,
useCodingCliSessionSignals,
} from './codingCliSessionSignalController';
test('connected sessions stop every icon mutation while dynamic titles are off and resume when enabled', () => {
let mode: DynamicTabTitleMode = 'agent';
const session: { id: string; codingCliProviderId?: CodingCliProviderId } = {
id: 'session-1',
codingCliProviderId: 'claude',
};
const providerUpdates: Array<CodingCliProviderId | null> = [];
const controller = createCodingCliSessionSignalController({
getDynamicTabTitleMode: () => mode,
getSession: (sessionId) => sessionId === session.id ? session : undefined,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => {
providerUpdates.push(providerId);
session.codingCliProviderId = providerId ?? undefined;
},
});
mode = 'off';
controller.handleTerminalOutput(session.id, 'Welcome to Claude Code');
controller.handleCommandSubmitted(session.id, 'opencode');
controller.handleTerminalTitleChange(session.id, null);
controller.handleTerminalTitleChange(session.id, 'root@host:~/project');
controller.handleTerminalTitleChange(session.id, 'OpenAI Codex');
assert.deepEqual(providerUpdates, []);
assert.equal(session.codingCliProviderId, 'claude');
mode = 'agent';
controller.handleTerminalTitleChange(session.id, 'root@host:~/project');
controller.handleCommandSubmitted(session.id, 'opencode');
assert.deepEqual(providerUpdates, [null, 'opencode']);
assert.equal(session.codingCliProviderId, 'opencode');
});
test('output scanning paused by the setting can detect a real banner after re-enable', () => {
let mode: DynamicTabTitleMode = 'off';
const session: { id: string; codingCliProviderId?: CodingCliProviderId } = { id: 'session-1' };
const providerUpdates: Array<CodingCliProviderId | null> = [];
const controller = createCodingCliSessionSignalController({
getDynamicTabTitleMode: () => mode,
getSession: () => session,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => {
providerUpdates.push(providerId);
session.codingCliProviderId = providerId ?? undefined;
},
});
controller.handleTerminalOutput(session.id, 'Welcome to Claude Code');
assert.deepEqual(providerUpdates, []);
mode = 'agent';
controller.handleTerminalOutput(session.id, 'Welcome to Claude Code');
assert.deepEqual(providerUpdates, ['claude']);
});
test('mode changes discard partial and exhausted output scanner state', () => {
let mode: DynamicTabTitleMode = 'agent';
const session: { id: string; codingCliProviderId?: CodingCliProviderId } = { id: 'session-1' };
const providerUpdates: Array<CodingCliProviderId | null> = [];
const controller = createCodingCliSessionSignalController({
getDynamicTabTitleMode: () => mode,
getSession: () => session,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => {
providerUpdates.push(providerId);
session.codingCliProviderId = providerId ?? undefined;
},
});
controller.handleTerminalOutput(session.id, 'Welcome to Claude ');
mode = 'off';
controller.handleDynamicTabTitleModeChange(mode);
controller.handleTerminalOutput(session.id, 'ignored while disabled');
mode = 'agent';
controller.handleDynamicTabTitleModeChange(mode);
controller.handleTerminalOutput(session.id, 'Code');
assert.deepEqual(providerUpdates, []);
controller.handleTerminalOutput(session.id, 'x'.repeat(16384));
mode = 'off';
controller.handleTerminalOutput(session.id, 'ignored while disabled');
mode = 'agent';
controller.handleTerminalOutput(session.id, 'Welcome to Claude Code');
assert.deepEqual(providerUpdates, ['claude']);
});
test('switching between enabled modes preserves exhausted output scans', () => {
let mode: DynamicTabTitleMode = 'agent';
const session: { id: string; codingCliProviderId?: CodingCliProviderId } = { id: 'session-1' };
const providerUpdates: Array<CodingCliProviderId | null> = [];
const controller = createCodingCliSessionSignalController({
getDynamicTabTitleMode: () => mode,
getSession: () => session,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => {
providerUpdates.push(providerId);
session.codingCliProviderId = providerId ?? undefined;
},
});
controller.handleTerminalOutput(session.id, 'x'.repeat(16384));
mode = 'all';
controller.handleDynamicTabTitleModeChange(mode);
controller.handleTerminalOutput(session.id, 'Welcome to Claude Code');
assert.deepEqual(providerUpdates, []);
});
test('useCodingCliSessionSignals preserves scan state and follows current props', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
type ProbeProps = {
mode: DynamicTabTitleMode;
sessionIds: string[];
session: { id: string; codingCliProviderId?: CodingCliProviderId };
onProvider: (providerId: CodingCliProviderId | null) => void;
};
let controller: CodingCliSessionSignalController | null = null;
const Probe = (props: ProbeProps) => {
controller = useCodingCliSessionSignals({
dynamicTabTitleMode: props.mode,
sessionIds: props.sessionIds,
getSession: (sessionId) => sessionId === props.session.id ? props.session : undefined,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => props.onProvider(providerId),
});
return null;
};
const session = { id: 'session-hook' };
const firstUpdates: Array<CodingCliProviderId | null> = [];
const latestUpdates: Array<CodingCliProviderId | null> = [];
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(React.createElement(Probe, {
mode: 'agent',
sessionIds: [session.id],
session,
onProvider: (providerId) => firstUpdates.push(providerId),
}));
});
controller!.handleTerminalOutput(session.id, 'Welcome to Claude ');
await act(async () => {
renderer!.update(React.createElement(Probe, {
mode: 'agent',
sessionIds: [session.id],
session,
onProvider: (providerId) => latestUpdates.push(providerId),
}));
});
controller!.handleTerminalOutput(session.id, 'Code');
assert.deepEqual(firstUpdates, []);
assert.deepEqual(latestUpdates, ['claude']);
session.codingCliProviderId = undefined;
await act(async () => {
renderer!.update(React.createElement(Probe, {
mode: 'off',
sessionIds: [session.id],
session,
onProvider: (providerId) => latestUpdates.push(providerId),
}));
});
controller!.handleCommandSubmitted(session.id, 'opencode');
assert.deepEqual(latestUpdates, ['claude']);
} finally {
await act(async () => renderer?.unmount());
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('useCodingCliSessionSignals forgets scans for sessions removed from props', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const session = { id: 'session-removed' };
const providerUpdates: Array<CodingCliProviderId | null> = [];
let sessionIds = [session.id];
let controller: CodingCliSessionSignalController | null = null;
const Probe = () => {
controller = useCodingCliSessionSignals({
dynamicTabTitleMode: 'agent',
sessionIds,
getSession: (sessionId) => sessionIds.includes(sessionId) ? session : undefined,
onUpdateSessionCodingCliProvider: (_sessionId, providerId) => {
providerUpdates.push(providerId);
},
});
return null;
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
controller!.handleTerminalOutput(session.id, 'x'.repeat(16384));
sessionIds = [];
await act(async () => renderer!.update(React.createElement(Probe)));
controller!.handleTerminalOutput(session.id, 'Welcome to Claude ');
sessionIds = [session.id];
await act(async () => renderer!.update(React.createElement(Probe)));
controller!.handleTerminalOutput(session.id, 'Code');
assert.deepEqual(providerUpdates, []);
controller!.handleTerminalOutput(session.id, '\nWelcome to Claude Code');
assert.deepEqual(providerUpdates, ['claude']);
} finally {
await act(async () => renderer?.unmount());
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});

View File

@@ -0,0 +1,236 @@
import { useEffect, useRef } from 'react';
import { matchCodingCliProviderFromCommand } from '../../domain/codingCliProviderMatch';
import {
createCodingCliOutputScanner,
type CodingCliOutputScanner,
} from '../../domain/codingCliOutputDetect';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import {
inferCodingCliProviderFromTitleSignals,
shouldClearCodingCliProviderForTitle,
} from '../../domain/codingCliTitleParse';
import type { DynamicTabTitleMode } from '../../domain/models';
import {
resolveCodingCliProviderIconUpdate,
shouldUpdateCodingCliTabIcon,
} from '../../domain/sessionTabTitle';
type CodingCliIconSession = {
id: string;
codingCliProviderId?: CodingCliProviderId;
};
export type CodingCliSessionSignalControllerDeps = {
getDynamicTabTitleMode: () => DynamicTabTitleMode;
getSession: (sessionId: string) => CodingCliIconSession | undefined;
onUpdateSessionCodingCliProvider?: (
sessionId: string,
providerId: CodingCliProviderId | null,
) => void;
onUpdateSessionDynamicTitle?: (sessionId: string, title: string | null) => void;
};
export type CodingCliSessionSignalController = {
handleDynamicTabTitleModeChange: (mode: DynamicTabTitleMode) => void;
handleCommandSubmitted: (sessionId: string, commandLine: string) => void;
handleTerminalOutput: (sessionId: string, chunk: string) => void;
handleTerminalTitleChange: (sessionId: string, title: string | null) => void;
forgetSession: (sessionId: string) => void;
};
type UseCodingCliSessionSignalsOptions = Omit<
CodingCliSessionSignalControllerDeps,
'getDynamicTabTitleMode'
> & {
dynamicTabTitleMode: DynamicTabTitleMode;
sessionIds: readonly string[];
};
export function createCodingCliSessionSignalController(
deps: CodingCliSessionSignalControllerDeps,
): CodingCliSessionSignalController {
const outputScanners = new Map<string, CodingCliOutputScanner>();
const outputScanDisabled = new Set<string>();
// TerminalLayer may memo-skip title/provider-only session updates, so the
// controller keeps its own provider memory instead of trusting a stale
// sessionsRef from a skipped render.
const knownProviderBySession = new Map<string, CodingCliProviderId | null>();
let observedDynamicTabTitleMode = deps.getDynamicTabTitleMode();
const resolveCurrentProviderId = (sessionId: string): CodingCliProviderId | null | undefined => {
if (knownProviderBySession.has(sessionId)) {
return knownProviderBySession.get(sessionId);
}
return deps.getSession(sessionId)?.codingCliProviderId;
};
const handleDynamicTabTitleModeChange = (mode: DynamicTabTitleMode) => {
if (mode === observedDynamicTabTitleMode) return;
const previousMode = observedDynamicTabTitleMode;
observedDynamicTabTitleMode = mode;
// agent <-> all both keep live detection on; clearing would re-arm
// exhausted startup scans and mis-tag ordinary mid-session output.
if (previousMode === 'off' || mode === 'off') {
outputScanners.clear();
outputScanDisabled.clear();
}
};
const getCurrentDynamicTabTitleMode = () => {
const mode = deps.getDynamicTabTitleMode();
handleDynamicTabTitleModeChange(mode);
return mode;
};
const applyProvider = (sessionId: string, providerId: CodingCliProviderId | null) => {
const session = deps.getSession(sessionId);
if (!session && !knownProviderBySession.has(sessionId)) return;
const nextProviderId = resolveCodingCliProviderIconUpdate({
dynamicTabTitleMode: getCurrentDynamicTabTitleMode(),
currentProviderId: resolveCurrentProviderId(sessionId),
nextProviderId: providerId,
});
if (nextProviderId === undefined) return;
knownProviderBySession.set(sessionId, nextProviderId);
deps.onUpdateSessionCodingCliProvider?.(sessionId, nextProviderId);
};
const handleCommandSubmitted = (sessionId: string, commandLine: string) => {
if (!shouldUpdateCodingCliTabIcon(getCurrentDynamicTabTitleMode())) return;
const provider = matchCodingCliProviderFromCommand(commandLine);
if (!provider) return;
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
applyProvider(sessionId, provider.id);
};
const handleTerminalTitleChange = (sessionId: string, title: string | null) => {
const session = deps.getSession(sessionId);
if (!session && !knownProviderBySession.has(sessionId)) return;
const dynamicTabTitleMode = getCurrentDynamicTabTitleMode();
const trimmedTitle = title?.trim();
const providerId = trimmedTitle
? inferCodingCliProviderFromTitleSignals(trimmedTitle)
: undefined;
const currentProviderId = resolveCurrentProviderId(sessionId);
const shouldStoreDynamicTitle =
dynamicTabTitleMode === 'all'
|| (
dynamicTabTitleMode === 'agent'
&& Boolean(currentProviderId || providerId)
);
deps.onUpdateSessionDynamicTitle?.(sessionId, shouldStoreDynamicTitle ? title : null);
if (!shouldUpdateCodingCliTabIcon(dynamicTabTitleMode)) return;
if (!trimmedTitle) {
if (currentProviderId) {
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
applyProvider(sessionId, null);
}
return;
}
if (providerId) {
if (!currentProviderId || currentProviderId !== providerId) {
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
applyProvider(sessionId, providerId);
}
return;
}
if (
currentProviderId
&& shouldClearCodingCliProviderForTitle(trimmedTitle, currentProviderId)
) {
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
applyProvider(sessionId, null);
}
};
const handleTerminalOutput = (sessionId: string, chunk: string) => {
const dynamicTabTitleMode = getCurrentDynamicTabTitleMode();
if (!chunk || outputScanDisabled.has(sessionId)) return;
if (!shouldUpdateCodingCliTabIcon(dynamicTabTitleMode)) return;
const session = deps.getSession(sessionId);
if (!session && !knownProviderBySession.has(sessionId)) {
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
return;
}
if (resolveCurrentProviderId(sessionId)) return;
let scanner = outputScanners.get(sessionId);
if (!scanner) {
scanner = createCodingCliOutputScanner();
outputScanners.set(sessionId, scanner);
}
const providerId = scanner.feed(chunk);
if (providerId) {
applyProvider(sessionId, providerId);
return;
}
if (scanner.isExhausted()) {
outputScanners.delete(sessionId);
outputScanDisabled.add(sessionId);
}
};
return {
handleDynamicTabTitleModeChange,
handleCommandSubmitted,
handleTerminalOutput,
handleTerminalTitleChange,
forgetSession: (sessionId: string) => {
outputScanners.delete(sessionId);
outputScanDisabled.delete(sessionId);
knownProviderBySession.delete(sessionId);
},
};
}
export function useCodingCliSessionSignals(
options: UseCodingCliSessionSignalsOptions,
): CodingCliSessionSignalController {
const optionsRef = useRef(options);
optionsRef.current = options;
const controllerRef = useRef<CodingCliSessionSignalController | null>(null);
if (!controllerRef.current) {
controllerRef.current = createCodingCliSessionSignalController({
getDynamicTabTitleMode: () => optionsRef.current.dynamicTabTitleMode,
getSession: (sessionId) => optionsRef.current.getSession(sessionId),
onUpdateSessionCodingCliProvider: (sessionId, providerId) => {
optionsRef.current.onUpdateSessionCodingCliProvider?.(sessionId, providerId);
},
onUpdateSessionDynamicTitle: (sessionId, title) => {
optionsRef.current.onUpdateSessionDynamicTitle?.(sessionId, title);
},
});
}
const controller = controllerRef.current;
const liveSessionIdsRef = useRef(new Set(options.sessionIds));
useEffect(() => {
controller.handleDynamicTabTitleModeChange(options.dynamicTabTitleMode);
}, [controller, options.dynamicTabTitleMode]);
useEffect(() => {
const nextSessionIds = new Set(options.sessionIds);
for (const sessionId of liveSessionIdsRef.current) {
if (!nextSessionIds.has(sessionId)) controller.forgetSession(sessionId);
}
liveSessionIdsRef.current = nextSessionIds;
}, [controller, options.sessionIds]);
useEffect(() => () => {
for (const sessionId of liveSessionIdsRef.current) {
controller.forgetSession(sessionId);
}
}, [controller]);
return controller;
}

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import commandBlocklistTable from '../../lib/commandBlocklist.json';
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
test('untouched legacy defaults gain the PowerShell group once', () => {
assert.deepEqual(
migrateLegacyCommandBlocklist(legacyDefaults),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test('customized legacy settings preserve removed defaults', () => {
const customized = legacyDefaults.slice(1);
assert.deepEqual(migrateLegacyCommandBlocklist(customized), customized);
assert.deepEqual(migrateLegacyCommandBlocklist([]), []);
});
test('legacy settings keep user additions while gaining new defaults', () => {
const customized = [...legacyDefaults, 'company-forbidden-command'];
assert.deepEqual(
migrateLegacyCommandBlocklist(customized),
[...customized, ...commandBlocklistTable.powershell],
);
});
test('a list that already contains a PowerShell default is left unchanged', () => {
const alreadyUpgraded = [...legacyDefaults, commandBlocklistTable.powershell[0]];
assert.deepEqual(migrateLegacyCommandBlocklist(alreadyUpgraded), alreadyUpgraded);
});

View File

@@ -0,0 +1,26 @@
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export function persistCommandBlocklistSetting(blocklist: string[]): boolean {
return localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, blocklist);
}
export function readCommandBlocklistSetting(): string[] {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (stored != null && !Array.isArray(stored)) {
return [...DEFAULT_COMMAND_BLOCKLIST];
}
const current = stored ?? [...DEFAULT_COMMAND_BLOCKLIST];
const migrated = stored == null ? current : migrateLegacyCommandBlocklist(current);
if (
stored == null
|| migrated.length !== current.length
|| migrated.some((pattern, index) => pattern !== current[index])
) {
persistCommandBlocklistSetting(migrated);
}
return migrated;
}

View File

@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog } from '../../domain/models.ts';
import {
EMPTY_CONNECTION_LOGS_SNAPSHOT,
getConnectionLogsActions,
getConnectionLogsSnapshot,
getEmptyConnectionLogsSnapshot,
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
subscribeConnectionLogs,
subscribeConnectionLogsActions,
} from './connectionLogsStore.ts';
const baseLog: ConnectionLog = {
id: 'log-1',
sessionId: 'session-1',
hostId: 'host-1',
hostLabel: 'Example',
hostname: 'example.com',
username: 'user',
protocol: 'ssh',
startTime: 1000,
localUsername: 'local',
localHostname: 'machine',
saved: false,
};
test('connectionLogsStore notifies subscribers only when the log array identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeConnectionLogs(() => {
events.push(getConnectionLogsSnapshot().connectionLogs.length);
});
const firstLogs = [baseLog];
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.at(-1), 1);
assert.equal(getConnectionLogsSnapshot().connectionLogs, firstLogs);
// Same identity must not wake the Vault logs section on unrelated renders.
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.length, 1);
const secondLogs = [baseLog, { ...baseLog, id: 'log-2', startTime: 2000 }];
publishConnectionLogsSnapshot({ connectionLogs: secondLogs });
assert.equal(events.at(-1), 2);
assert.equal(events.length, 2);
unsubscribe();
publishConnectionLogsSnapshot({ connectionLogs: [] });
assert.equal(events.length, 2);
});
test('connectionLogsStore exposes a frozen empty snapshot for gated mounts', () => {
assert.equal(getEmptyConnectionLogsSnapshot(), EMPTY_CONNECTION_LOGS_SNAPSHOT);
assert.equal(getEmptyConnectionLogsSnapshot().connectionLogs.length, 0);
assert.equal(Object.isFrozen(EMPTY_CONNECTION_LOGS_SNAPSHOT), true);
});
test('registerConnectionLogsActions exposes the vault log mutators', () => {
const calls: string[] = [];
const actionEvents: number[] = [];
const unsubscribe = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
registerConnectionLogsActions({
updateConnectionLog: () => {
calls.push('update');
},
toggleConnectionLogSaved: () => {
calls.push('toggle');
},
deleteConnectionLog: () => {
calls.push('delete');
},
clearUnsavedConnectionLogs: () => {
calls.push('clear');
},
});
const actions = getConnectionLogsActions();
assert.ok(actions);
actions.updateConnectionLog('log-1', { saved: true });
actions.toggleConnectionLogSaved('log-1');
actions.deleteConnectionLog('log-1');
actions.clearUnsavedConnectionLogs();
assert.deepEqual(calls, ['update', 'toggle', 'delete', 'clear']);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
assert.equal(getConnectionLogsActions(), null);
assert.equal(actionEvents.length, 2);
unsubscribe();
});
test('connectionLogsStore keeps values and actions on independent slots', () => {
const valueEvents: number[] = [];
const actionEvents: number[] = [];
const unsubValues = subscribeConnectionLogs(() => {
valueEvents.push(valueEvents.length);
});
const unsubActions = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
// Re-registering unstable mutator identities must not invalidate the logs.
registerConnectionLogsActions({
updateConnectionLog: () => {},
toggleConnectionLogSaved: () => {},
deleteConnectionLog: () => {},
clearUnsavedConnectionLogs: () => {},
});
assert.equal(valueEvents.length, 0);
assert.equal(actionEvents.length, 1);
publishConnectionLogsSnapshot({ connectionLogs: [baseLog] });
assert.equal(valueEvents.length, 1);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
unsubValues();
unsubActions();
});

View File

@@ -0,0 +1,135 @@
import { useSyncExternalStore } from 'react';
import type { ConnectionLog } from '../../domain/models';
type Listener = () => void;
export type ConnectionLogsSnapshot = {
connectionLogs: readonly ConnectionLog[];
};
export type ConnectionLogsActions = {
updateConnectionLog: (id: string, updates: Partial<ConnectionLog>) => void;
toggleConnectionLogSaved: (id: string) => void;
deleteConnectionLog: (id: string) => void;
clearUnsavedConnectionLogs: () => void;
};
const EMPTY_CONNECTION_LOGS: readonly ConnectionLog[] = Object.freeze([]);
export const EMPTY_CONNECTION_LOGS_SNAPSHOT: ConnectionLogsSnapshot = Object.freeze({
connectionLogs: EMPTY_CONNECTION_LOGS,
});
/**
* External store for connection logs so the Vault logs section and log-view
* replays can subscribe without keeping logs in the App domain bags. Every
* session start/exit appends a log, which would otherwise rebuild the whole
* shell.
*/
class ConnectionLogsStore {
private snapshot: ConnectionLogsSnapshot = EMPTY_CONNECTION_LOGS_SNAPSHOT;
private actions: ConnectionLogsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): ConnectionLogsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: ConnectionLogsSnapshot): void {
if (this.snapshot.connectionLogs === next.connectionLogs) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): ConnectionLogsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: ConnectionLogsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const connectionLogsStore = new ConnectionLogsStore();
export function publishConnectionLogsSnapshot(
snapshot: ConnectionLogsSnapshot,
): void {
connectionLogsStore.setSnapshot(snapshot);
}
export function getConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return connectionLogsStore.getSnapshot();
}
export function subscribeConnectionLogs(listener: Listener): () => void {
return connectionLogsStore.subscribe(listener);
}
export function getEmptyConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return EMPTY_CONNECTION_LOGS_SNAPSHOT;
}
export function registerConnectionLogsActions(
actions: ConnectionLogsActions | null,
): void {
connectionLogsStore.setActions(actions);
}
export function getConnectionLogsActions(): ConnectionLogsActions | null {
return connectionLogsStore.getActions();
}
export function subscribeConnectionLogsActions(listener: Listener): () => void {
return connectionLogsStore.subscribeActions(listener);
}
const noopUpdateConnectionLog: ConnectionLogsActions['updateConnectionLog'] = () => {};
const noopToggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'] = () => {};
const noopDeleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'] = () => {};
const noopClearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'] = () => {};
/** Subscribe to the connection log catalog plus its vault mutation actions. */
export function useConnectionLogsStore(): {
connectionLogs: ConnectionLog[];
updateConnectionLog: ConnectionLogsActions['updateConnectionLog'];
toggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'];
deleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'];
clearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'];
} {
const snapshot = useSyncExternalStore(
subscribeConnectionLogs,
getConnectionLogsSnapshot,
getConnectionLogsSnapshot,
);
const actions = useSyncExternalStore(
subscribeConnectionLogsActions,
getConnectionLogsActions,
getConnectionLogsActions,
);
return {
connectionLogs: snapshot.connectionLogs as ConnectionLog[],
updateConnectionLog: actions?.updateConnectionLog ?? noopUpdateConnectionLog,
toggleConnectionLogSaved: actions?.toggleConnectionLogSaved ?? noopToggleConnectionLogSaved,
deleteConnectionLog: actions?.deleteConnectionLog ?? noopDeleteConnectionLog,
clearUnsavedConnectionLogs: actions?.clearUnsavedConnectionLogs ?? noopClearUnsavedConnectionLogs,
};
}

View File

@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession, Workspace } from '../../domain/models';
import { collectSessionIds } from '../../domain/workspace';
import { buildCopiedWorkspace } from './useSessionState';
const session = (id: string, workspaceId?: string): TerminalSession => ({
id,
hostId: `host-${id}`,
hostLabel: `Host ${id}`,
hostname: `${id}.example.test`,
username: 'user',
status: 'connected',
protocol: 'ssh',
workspaceId,
});
const wsRoot = {
id: 'split-1',
type: 'split' as const,
direction: 'vertical' as const,
sizes: [0.6, 0.4],
children: [
{ id: 'pane-1', type: 'pane' as const, sessionId: 's1' },
{ id: 'pane-2', type: 'pane' as const, sessionId: 's2' },
],
};
const sourceWorkspace: Workspace = {
id: 'ws-src',
title: 'My Split',
viewMode: 'split',
focusedSessionId: 's2',
focusSessionOrder: ['s1', 's2'],
root: wsRoot,
};
test('buildCopiedWorkspace clones every session with the new workspace id and inherited cwd', () => {
const prev = [session('s1', 'ws-src'), session('s2', 'ws-src')];
const built = buildCopiedWorkspace(sourceWorkspace, prev, {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['s1', 'n1'], ['s2', 'n2']]),
perPaneCwd: { s1: '/home/a', s2: '/home/b' },
});
assert.ok(built);
assert.deepEqual(built.newSessions.map(s => s.id), ['n1', 'n2']);
assert.ok(built.newSessions.every(s => s.workspaceId === 'ws-new'));
assert.equal(built.newSessions[0].pendingInitialCwd, '/home/a');
assert.equal(built.newSessions[1].pendingInitialCwd, '/home/b');
});
test('buildCopiedWorkspace rebuilds the tree with new ids and preserves view mode + remapped focus', () => {
const prev = [session('s1', 'ws-src'), session('s2', 'ws-src')];
const built = buildCopiedWorkspace(sourceWorkspace, prev, {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['s1', 'n1'], ['s2', 'n2']]),
});
assert.ok(built);
assert.equal(built.newWorkspace.id, 'ws-new');
assert.equal(built.newWorkspace.viewMode, 'split');
assert.equal(built.newWorkspace.title, 'My Split');
assert.deepEqual(collectSessionIds(built.newWorkspace.root), ['n1', 'n2']);
assert.equal(built.newWorkspace.focusedSessionId, 'n2');
assert.deepEqual(built.newWorkspace.focusSessionOrder, ['n1', 'n2']);
const ids = collectSessionIds(built.newWorkspace.root);
assert.ok(!ids.includes('s1') && !ids.includes('s2'));
});
test('buildCopiedWorkspace prunes panes whose source session no longer exists', () => {
const prev = [session('s2', 'ws-src')]; // s1 was closed
const built = buildCopiedWorkspace(sourceWorkspace, prev, {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['s1', 'n1'], ['s2', 'n2']]),
});
assert.ok(built);
assert.deepEqual(built.newSessions.map(s => s.id), ['n2']);
assert.deepEqual(collectSessionIds(built.newWorkspace.root), ['n2']);
assert.equal(built.newWorkspace.focusedSessionId, 'n2');
assert.deepEqual(built.newWorkspace.focusSessionOrder, ['n2']);
});
test('buildCopiedWorkspace returns null when no source session survives', () => {
const built = buildCopiedWorkspace(sourceWorkspace, [], {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['s1', 'n1'], ['s2', 'n2']]),
});
assert.equal(built, null);
});
test('buildCopiedWorkspace routes inherited cwd to localStartDir for local sessions', () => {
const localSrc: Workspace = {
id: 'ws-src',
title: 'Local Split',
root: {
id: 'sp', type: 'split', direction: 'vertical',
children: [
{ id: 'p1', type: 'pane', sessionId: 'l1' },
{ id: 'p2', type: 'pane', sessionId: 'l2' },
],
},
};
const local = (id: string): TerminalSession => ({
id, hostId: `h-${id}`, hostLabel: 'Local', hostname: 'local',
username: 'user', status: 'connected', protocol: 'local',
localStartDir: '/', workspaceId: 'ws-src',
});
const built = buildCopiedWorkspace(localSrc, [local('l1'), local('l2')], {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['l1', 'm1'], ['l2', 'm2']]),
perPaneCwd: { l1: '/home/a', l2: '/home/b' },
});
assert.ok(built);
assert.equal(built.newSessions[0].localStartDir, '/home/a');
assert.equal(built.newSessions[1].localStartDir, '/home/b');
assert.equal(built.newSessions[0].pendingInitialCwd, undefined);
});
test('buildCopiedWorkspace prunes multiple dead sessions across a nested tree', () => {
const nested: Workspace = {
id: 'ws-src', title: 'Nested',
root: {
id: 'r', type: 'split', direction: 'vertical',
children: [
{ id: 'pa', type: 'pane', sessionId: 'a' },
{
id: 'inner', type: 'split', direction: 'horizontal',
children: [
{ id: 'pb', type: 'pane', sessionId: 'b' },
{ id: 'pc', type: 'pane', sessionId: 'c' },
],
},
],
},
};
const s = (id: string): TerminalSession => ({
id, hostId: `h-${id}`, hostLabel: 'H', hostname: 'h',
username: 'u', status: 'connected', protocol: 'ssh', workspaceId: 'ws-src',
});
// Only 'b' survives; 'a' and 'c' are gone.
const built = buildCopiedWorkspace(nested, [s('b')], {
newWorkspaceId: 'ws-new',
sessionIdMap: new Map([['a', 'na'], ['b', 'nb'], ['c', 'nc']]),
});
assert.ok(built);
assert.deepEqual(built.newSessions.map(x => x.id), ['nb']);
assert.deepEqual(collectSessionIds(built.newWorkspace.root), ['nb']);
});

View File

@@ -0,0 +1,204 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync.ts';
/**
* Minimal model of the settings ↔ main custom-accent sync loop that caused
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
* the native color picker fires rapid onChange events.
*/
function simulateAccentDrag(options: {
shouldBroadcast: (
source: CustomAccentMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
versioned: boolean;
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let settingsSource: CustomAccentMutationSource = 'local';
let mainSource: CustomAccentMutationSource = 'local';
let storage = options.versioned
? serializeCustomAccentRecord(settings)
: settings.color;
const settingsValues: string[] = [];
const mainValues: string[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
const writeStorage = (record: CustomAccentRecord) => {
const next = options.versioned
? serializeCustomAccentRecord(record)
: record.color;
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', color: string) => {
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
options.versioned
? { color, version: prev.version + 1 }
: { color, version: 0 }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings);
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main);
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = options.versioned
? parseCustomAccentRecord(JSON.parse(storage))
: parseCustomAccentRecord(storage);
applyIncoming(window, record);
};
applyLocal('settings', '0 84% 60%');
deliverStorageTo('main');
applyLocal('settings', '199 89% 48%');
// Delayed IPC for the older color arrives after the newer local drag sample.
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
assert.deepEqual(
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
{ color: '0 84% 60%', version: 3 },
);
assert.deepEqual(
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
{ color: '262.1 83.3% 57.8%', version: 9 },
);
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
});
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
const current = { color: '199 89% 48%', version: 2 };
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
});
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastCustomAccentChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
const alwaysBroadcast = (
_source: CustomAccentMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateAccentDrag({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
);
});
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
const { settingsValues, mainValues } = simulateAccentDrag({
shouldBroadcast: shouldBroadcastCustomAccentChange,
shouldApply: shouldApplyCustomAccentRecord,
versioned: true,
});
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
assert.ok(mainValues.includes('199 89% 48%'));
assert.equal(
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
false,
);
});
test('serializeCustomAccentRecord round-trips through parse', () => {
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
color: '262.1 83.3% 57.8%',
version: 9,
});
});

View File

@@ -0,0 +1,93 @@
import { DEFAULT_CUSTOM_ACCENT, isValidHslToken } from './settingsStateDefaults';
export type CustomAccentMutationSource = 'local' | 'incoming';
export type CustomAccentRecord = {
color: string;
version: number;
};
const FALLBACK_RECORD: CustomAccentRecord = {
color: DEFAULT_CUSTOM_ACCENT,
version: 0,
};
function normalizeAccentColor(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isValidHslToken(trimmed) ? trimmed : null;
}
/**
* Parse persisted / IPC custom-accent payloads.
* Accepts legacy plain HSL tokens ("221.2 83.2% 53.3%") and versioned records.
*/
export function parseCustomAccentRecord(raw: unknown): CustomAccentRecord {
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
return parseCustomAccentRecord(JSON.parse(trimmed));
} catch {
// fall through to plain HSL
}
}
const color = normalizeAccentColor(trimmed);
if (color) return { color, version: 0 };
}
if (raw && typeof raw === 'object') {
const record = raw as { color?: unknown; version?: unknown };
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
const version = Number(record.version);
return {
color,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
};
}
return { ...FALLBACK_RECORD };
}
export function serializeCustomAccentRecord(record: CustomAccentRecord): string {
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
return JSON.stringify({
color,
version: Math.max(0, Math.floor(record.version) || 0),
});
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
*/
export function shouldApplyCustomAccentRecord(
current: CustomAccentRecord,
incoming: CustomAccentRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
// Same version: only apply when the color itself differs and both are
// legacy/unversioned (version 0), so first-load plain strings still sync.
if (incoming.version === 0 && current.version === 0) {
return incoming.color !== current.color;
}
return false;
}
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* windows. Incoming IPC/storage updates must not notify again - otherwise a
* fast native color-picker drag in the settings window ping-pongs with the
* main window and the accent CSS variables oscillate (see #2743, same class
* as window-opacity #2018).
*/
export function shouldBroadcastCustomAccentChange(
mutationSource: CustomAccentMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}

View File

@@ -0,0 +1,172 @@
import { useSyncExternalStore, useCallback } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
// Access the Electron bridge for cross-window IPC
type NetcattyBridge = {
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
};
const getBridge = (): NetcattyBridge | undefined =>
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
/**
* Custom Theme Store - manages user-created terminal themes
* Uses useSyncExternalStore pattern (same as fontStore)
* Persists to localStorage + cross-window IPC sync
*/
type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
constructor() {
this.loadFromStorage();
this.setupCrossWindowSync();
}
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
}
} catch {
// ignore corrupt data
}
this.notify();
};
private saveToStorage = () => {
try {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
} catch {
// storage full or unavailable
}
};
private notify = () => {
this.cachedAllThemes = null; // invalidate cache on any mutation
this.listeners.forEach(listener => listener());
};
/** Broadcast change to other Electron windows via IPC */
private broadcastChange = () => {
try {
getBridge()?.notifySettingsChanged?.({
key: STORAGE_KEY_CUSTOM_THEMES,
value: this.themes,
});
} catch {
// not in Electron or bridge unavailable
}
};
/** Listen for changes from other windows and reload */
private setupCrossWindowSync = () => {
try {
getBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
}
});
} catch {
// not in Electron or bridge unavailable
}
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
// ---- Getters (stable references for useSyncExternalStore) ----
getCustomThemes = (): TerminalTheme[] => this.themes;
/** Returns all themes: built-in + custom (cached for snapshot stability) */
getAllThemes = (): TerminalTheme[] => {
if (!this.cachedAllThemes) {
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
}
return this.cachedAllThemes;
};
/** Find a theme by ID across both built-in and custom */
getThemeById = (id: string): TerminalTheme | undefined => {
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
};
// ---- Mutations ----
addTheme = (theme: TerminalTheme) => {
this.themes = [...this.themes, { ...theme, isCustom: true }];
this.saveToStorage();
this.notify();
this.broadcastChange();
};
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
this.themes = this.themes.map(t =>
t.id === id ? { ...t, ...updates, isCustom: true } : t
);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
deleteTheme = (id: string) => {
this.themes = this.themes.filter(t => t.id !== id);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getCustomThemes
);
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
customThemeStore.addTheme(theme);
}, []);
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
customThemeStore.updateTheme(id, updates);
}, []);
const deleteTheme = useCallback((id: string) => {
customThemeStore.deleteTheme(id);
}, []);
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};

View File

@@ -0,0 +1,960 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearKeyPassphrasesByIds,
clearRememberedKeyPassphrases,
clearReferenceKeyPassphrases,
deleteVaultKey,
loadDefaultKeyPassphrase,
readDefaultKeyPassphraseForExport,
readDefaultKeyPassphrasesForVerification,
readRememberedKeyPassphrases,
rememberImportedKeyPassphrase,
rememberKeyPassphrase,
removeDefaultKeyPassphraseAliases,
saveDefaultKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from "../defaultKeyPassphrases";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
import type { SSHKey } from "../../domain/models";
function installLocalStorage(t: test.TestContext): void {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: undefined },
});
t.after(() => {
Reflect.deleteProperty(globalThis, "localStorage");
Reflect.deleteProperty(globalThis, "window");
});
}
const referenceKey = (): SSHKey => ({
id: "reference-key",
label: "id_ed25519",
type: "ED25519",
category: "key",
source: "reference",
filePath: "/Users/alice/.ssh/id_ed25519",
privateKey: "",
created: 1,
});
test("deleting a reference key also forgets its remembered passphrase", async (t) => {
installLocalStorage(t);
const key = referenceKey();
let keys = [key];
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
await deleteVaultKey({
keyId: key.id,
getKeys: () => keys,
updateKeys: (updated) => {
keys = updated;
},
});
assert.deepEqual(keys, []);
assert.equal(await loadDefaultKeyPassphrase(key.filePath!), null);
});
test("deleting one reference keeps the passphrase while another key uses the same file", async (t) => {
installLocalStorage(t);
const key = referenceKey();
const duplicate = { ...key, id: "duplicate-reference" };
let keys = [key, duplicate];
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
await deleteVaultKey({
keyId: key.id,
getKeys: () => keys,
updateKeys: (updated) => {
keys = updated;
},
});
assert.deepEqual(keys, [duplicate]);
assert.equal(
await loadDefaultKeyPassphrase(key.filePath!),
"remembered-passphrase",
);
});
test("deleting a key cannot overwrite a concurrent key-list update", async (t) => {
installLocalStorage(t);
const key = referenceKey();
const concurrentReference = { ...key, id: "concurrent-reference" };
let keys = [key];
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
let releaseHomeLookup: (() => void) | undefined;
const homeLookup = new Promise<void>((resolve) => {
releaseHomeLookup = resolve;
});
let homeLookupCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => {
homeLookupCount += 1;
await homeLookup;
return "/Users/alice";
} } },
});
const deletion = deleteVaultKey({
keyId: key.id,
getKeys: () => keys,
updateKeys: (updated) => {
keys = updated;
},
});
assert.deepEqual(keys, []);
keys = [concurrentReference];
releaseHomeLookup?.();
await deletion;
assert.deepEqual(keys, [concurrentReference]);
assert.equal(homeLookupCount, 1);
assert.equal(
await loadDefaultKeyPassphrase(key.filePath!),
"remembered-passphrase",
);
});
test("deleting a Windows reference clears remembered path aliases", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "C:\\Users\\Alice" } },
});
const key = {
...referenceKey(),
filePath: "~/.ssh/id_ed25519",
};
let keys = [key];
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "old-relative",
"C:\\Users\\Alice\\.ssh\\id_ed25519": "old-native",
"C:/Users/Alice/.ssh/id_ed25519": "old-normalized",
"C:/Users/Alice/.ssh/other": "keep",
}),
);
await deleteVaultKey({
keyId: key.id,
getKeys: () => keys,
updateKeys: (updated) => {
keys = updated;
},
});
assert.deepEqual(keys, []);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "C:/Users/Alice/.ssh/other": "keep" },
);
});
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
const result = await loadDefaultKeyPassphrase(keyPath);
assert.equal(result, null);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
);
});
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: encrypted }),
);
assert.deepEqual(
await readDefaultKeyPassphraseForExport(keyPath),
{ status: "unreadable" },
);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ [keyPath]: encrypted },
);
});
test("export read retries when the passphrase changes during decryption", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
let releaseDecrypt: (() => void) | undefined;
const decryptGate = new Promise<void>((resolve) => {
releaseDecrypt = resolve;
});
let firstDecryptStarted: (() => void) | undefined;
const firstDecrypt = new Promise<void>((resolve) => {
firstDecryptStarted = resolve;
});
let decryptCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => "/Users/alice",
credentialsDecrypt: async (value: string) => {
decryptCount += 1;
if (decryptCount === 1) {
firstDecryptStarted?.();
await decryptGate;
return "old-secret";
}
return value;
},
},
},
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: encrypted }),
);
const pendingRead = readDefaultKeyPassphraseForExport(keyPath);
await firstDecrypt;
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "new-secret" }),
);
releaseDecrypt?.();
assert.deepEqual(await pendingRead, { status: "readable", value: "new-secret" });
});
test("remembered passphrase read includes Keychain reference-key values", async (t) => {
installLocalStorage(t);
const key = {
...referenceKey(),
passphrase: "keychain-secret",
savePassphrase: true,
};
assert.deepEqual(
await readRememberedKeyPassphrases(key.filePath!, [key]),
{ values: ["keychain-secret"], unreadable: false },
);
});
test("remembered passphrase read marks encrypted Keychain placeholders unreadable", async (t) => {
installLocalStorage(t);
const key = {
...referenceKey(),
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
savePassphrase: true,
};
assert.deepEqual(
await readRememberedKeyPassphrases(key.filePath!, [key]),
{ values: [], unreadable: true },
);
});
test("passphrase verification reads every alias and preserves unreadable state", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => "/Users/alice",
credentialsDecrypt: async (value: string) => value,
},
},
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "readable-secret",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
}),
);
assert.deepEqual(
await readDefaultKeyPassphrasesForVerification("~/.ssh/id_ed25519"),
{ values: ["readable-secret"], unreadable: true, present: true },
);
assert.deepEqual(
await readRememberedKeyPassphrases("~/.ssh/id_ed25519", []),
{ values: ["readable-secret"], unreadable: true },
);
});
test("passphrase verification preserves conflicting readable alias values", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "relative-secret",
"/Users/alice/.ssh/id_ed25519": "absolute-secret",
}),
);
const read = await readRememberedKeyPassphrases("~/.ssh/id_ed25519", []);
assert.deepEqual(new Set(read.values), new Set(["relative-secret", "absolute-secret"]));
assert.equal(read.unreadable, false);
});
test("imported passphrase cannot overwrite a correction queued first", async (t) => {
installLocalStorage(t);
let releaseEncrypt: (() => void) | undefined;
const encryptGate = new Promise<void>((resolve) => {
releaseEncrypt = resolve;
});
let encryptStarted: (() => void) | undefined;
const firstEncrypt = new Promise<void>((resolve) => {
encryptStarted = resolve;
});
let encryptCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
credentialsEncrypt: async (value: string) => {
encryptCount += 1;
if (encryptCount === 1) {
encryptStarted?.();
await encryptGate;
}
return value;
},
},
},
});
const correction = saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "current-secret");
await firstEncrypt;
const imported = rememberImportedKeyPassphrase({
keyPath: "~/.ssh/id_ed25519",
passphrase: "stale-import-secret",
keys: [],
updateKeys: () => {},
});
releaseEncrypt?.();
await correction;
assert.equal(await imported, "conflict");
assert.equal(await loadDefaultKeyPassphrase("~/.ssh/id_ed25519"), "current-secret");
});
test("imported passphrase cannot overwrite a direct key correction during validation", async (t) => {
installLocalStorage(t);
let releaseEncrypt: (() => void) | undefined;
const encryptGate = new Promise<void>((resolve) => {
releaseEncrypt = resolve;
});
let encryptStarted: (() => void) | undefined;
const encryption = new Promise<void>((resolve) => {
encryptStarted = resolve;
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => "/Users/alice",
credentialsEncrypt: async (value: string) => {
encryptStarted?.();
await encryptGate;
return value;
},
},
},
});
let keys: SSHKey[] = [{
...referenceKey(),
passphrase: "stale-import",
savePassphrase: true,
}];
const imported = rememberImportedKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "stale-import",
keys,
getKeys: () => keys,
setCurrentKeys: (updated) => {
keys = updated;
},
updateKeys: () => undefined,
});
await encryption;
keys = [{ ...keys[0], passphrase: "user-correction" }];
releaseEncrypt?.();
assert.equal(await imported, "conflict");
assert.equal(keys[0]?.passphrase, "user-correction");
assert.equal(await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"), null);
});
test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently", async (t) => {
installLocalStorage(t);
let releaseFirstHomeLookup: (() => void) | undefined;
const firstHomeLookup = new Promise<void>((resolve) => {
releaseFirstHomeLookup = resolve;
});
let homeLookupCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => {
homeLookupCount += 1;
if (homeLookupCount === 1) await firstHomeLookup;
return "/Users/alice";
},
},
},
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
);
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
await saveDefaultKeyPassphrase("/Users/alice/.ssh/id_new", "new-passphrase");
releaseFirstHomeLookup?.();
assert.equal(await pendingLoad, null);
assert.equal(
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_new"),
"new-passphrase",
);
});
test("loadDefaultKeyPassphrase retries when the same path is saved concurrently", async (t) => {
installLocalStorage(t);
let releaseFirstHomeLookup: (() => void) | undefined;
const firstHomeLookup = new Promise<void>((resolve) => {
releaseFirstHomeLookup = resolve;
});
let homeLookupCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => {
homeLookupCount += 1;
if (homeLookupCount === 1) await firstHomeLookup;
return "/Users/alice";
},
},
},
});
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "old-passphrase" }),
);
const pendingLoad = loadDefaultKeyPassphrase(keyPath);
await saveDefaultKeyPassphrase(keyPath, "new-passphrase");
releaseFirstHomeLookup?.();
assert.equal(await pendingLoad, "new-passphrase");
});
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
);
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
});
test("saveDefaultKeyPassphrase makes the passphrase available to the connection prompt", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
await saveDefaultKeyPassphrase(keyPath, "saved by agent");
assert.equal(await loadDefaultKeyPassphrase(keyPath), "saved by agent");
});
test("loadDefaultKeyPassphrase matches an expanded connection path to a saved home-relative path", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => "/Users/alice",
},
},
});
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "saved by agent");
assert.equal(
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
"saved by agent",
);
});
test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
}),
);
assert.equal(
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
"valid-exact-passphrase",
);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase" },
);
});
test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invalid exact value", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "valid-alias-passphrase",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
}),
);
assert.equal(
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
"valid-alias-passphrase",
);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "~/.ssh/id_ed25519": "valid-alias-passphrase" },
);
});
test("loadDefaultKeyPassphrase consolidates conflicting valid aliases around the exact path", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "old-alias-passphrase",
"/Users/alice/.ssh/id_ed25519": "new-exact-passphrase",
}),
);
assert.equal(
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
"new-exact-passphrase",
);
assert.equal(
await loadDefaultKeyPassphrase("~/.ssh/id_ed25519"),
"new-exact-passphrase",
);
});
test("saveDefaultKeyPassphrase replaces stale values stored under path aliases", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "old-relative",
"/Users/alice/.ssh/id_ed25519": "old-absolute",
}),
);
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "replacement");
const stored = JSON.parse(
globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}",
) as Record<string, string>;
assert.equal(stored["/Users/alice/.ssh/id_ed25519"], undefined);
assert.equal(await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"), "replacement");
});
test("removeDefaultKeyPassphraseAliases clears relative and expanded paths", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "old-relative",
"/Users/alice/.ssh/id_ed25519": "old-absolute",
"/Users/alice/.ssh/other": "keep",
}),
);
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
assert.deepEqual(new Set(aliases), new Set([
"~/.ssh/id_ed25519",
"/Users/alice/.ssh/id_ed25519",
]));
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/other": "keep" },
);
const clearedKeys = clearReferenceKeyPassphrases([
{ ...referenceKey(), passphrase: "old", savePassphrase: true },
], aliases);
assert.equal(clearedKeys[0].passphrase, undefined);
assert.equal(clearedKeys[0].savePassphrase, false);
});
test("passphrase removal and save mutations run in request order", async (t) => {
installLocalStorage(t);
let releaseFirstHomeLookup: (() => void) | undefined;
const firstHomeLookup = new Promise<void>((resolve) => {
releaseFirstHomeLookup = resolve;
});
let homeLookupCount = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
getHomeDir: async () => {
homeLookupCount += 1;
if (homeLookupCount === 1) await firstHomeLookup;
return "/Users/alice";
},
},
},
});
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "old-passphrase" }),
);
const pendingRemoval = removeDefaultKeyPassphraseAliases([keyPath]);
const pendingSave = saveDefaultKeyPassphrase(keyPath, "corrected-passphrase");
releaseFirstHomeLookup?.();
assert.deepEqual(await pendingRemoval, [keyPath, "~/.ssh/id_ed25519"]);
await pendingSave;
assert.equal(await loadDefaultKeyPassphrase(keyPath), "corrected-passphrase");
});
test("credential clearing holds the mutation queue until key state is persisted", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
let keys: SSHKey[] = [{
...referenceKey(),
passphrase: "old-secret",
savePassphrase: true,
}];
await saveDefaultKeyPassphrase(keyPath, "old-secret");
let releaseClear: (() => void) | undefined;
const clearPersisted = new Promise<void>((resolve) => {
releaseClear = resolve;
});
let clearUpdateStarted: (() => void) | undefined;
const clearStarted = new Promise<void>((resolve) => {
clearUpdateStarted = resolve;
});
const clearing = clearRememberedKeyPassphrases({
keyPaths: [keyPath],
getKeys: () => keys,
setCurrentKeys: (updated) => {
keys = updated;
},
updateKeys: async () => {
clearUpdateStarted?.();
await clearPersisted;
},
});
await clearStarted;
let importFinished = false;
const importing = rememberImportedKeyPassphrase({
keyPath,
passphrase: "imported-secret",
keys,
getKeys: () => keys,
setCurrentKeys: (updated) => {
keys = updated;
},
updateKeys: () => undefined,
}).then((result) => {
importFinished = true;
return result;
});
await Promise.resolve();
assert.equal(importFinished, false);
releaseClear?.();
await clearing;
assert.equal(await importing, "saved");
assert.equal(keys[0]?.passphrase, "imported-secret");
});
test("rememberKeyPassphrase updates a reference key stored under an expanded alias", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
});
let updatedKeys: SSHKey[] | undefined;
await rememberKeyPassphrase({
keyPath: "~/.ssh/id_ed25519",
passphrase: "replacement",
keys: [{ ...referenceKey(), passphrase: "old", savePassphrase: true }],
updateKeys: (keys) => {
updatedKeys = keys;
},
});
assert.equal(updatedKeys?.[0].passphrase, "replacement");
assert.equal(updatedKeys?.[0].savePassphrase, true);
});
test("rememberKeyPassphrase reads the latest keys after saving the credential", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
let currentKeys: SSHKey[] = [{
id: "key-1",
name: "Original",
type: "ed25519",
source: "reference",
filePath: keyPath,
createdAt: 1,
}];
const staleKeys = currentKeys;
const updatedNames: string[] = [];
currentKeys = [{ ...currentKeys[0], name: "Concurrent edit" }];
await rememberKeyPassphrase({
keyPath,
passphrase: "secret",
keys: staleKeys,
getKeys: () => currentKeys,
updateKeys: (updated) => {
currentKeys = updated;
updatedNames.push(updated[0]?.name ?? "");
},
});
assert.deepEqual(updatedNames, ["Concurrent edit"]);
assert.equal(currentKeys[0]?.passphrase, "secret");
});
test("path aliases replace and clear Windows reference-key spellings", async (t) => {
installLocalStorage(t);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: { getHomeDir: async () => "C:\\Users\\Alice" } },
});
const windowsReferenceKey: SSHKey = {
...referenceKey(),
filePath: "c:\\users\\alice\\.ssh\\id_ed25519",
passphrase: "old",
savePassphrase: true,
};
let updatedKeys: SSHKey[] | undefined;
await rememberKeyPassphrase({
keyPath: "~/.ssh/id_ed25519",
passphrase: "replacement",
keys: [windowsReferenceKey],
updateKeys: (keys) => {
updatedKeys = keys;
},
});
assert.equal(updatedKeys?.[0].passphrase, "replacement");
assert.equal(
await loadDefaultKeyPassphrase("C:\\Users\\Alice\\.ssh\\id_ed25519"),
"replacement",
);
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
const clearedKeys = clearReferenceKeyPassphrases(updatedKeys ?? [], aliases);
assert.equal(clearedKeys[0].passphrase, undefined);
assert.equal(await loadDefaultKeyPassphrase("c:\\users\\alice\\.ssh\\id_ed25519"), null);
});
test("POSIX backslashes remain distinct from path separators", async (t) => {
installLocalStorage(t);
await saveDefaultKeyPassphrase("/home/alice/.ssh/team\\key", "backslash-name");
await saveDefaultKeyPassphrase("/home/alice/.ssh/team/key", "nested-path");
assert.equal(
await loadDefaultKeyPassphrase("/home/alice/.ssh/team\\key"),
"backslash-name",
);
assert.equal(
await loadDefaultKeyPassphrase("/home/alice/.ssh/team/key"),
"nested-path",
);
});
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
filePath: "/Users/alice/.ssh/other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
id: "inline-key",
source: "imported",
filePath: undefined,
privateKey: "PRIVATE KEY",
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}),
true,
);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "saved",
}),
false,
);
});
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
installLocalStorage(t);
const keys = [referenceKey()];
let currentKeys = keys;
let releaseUpdate: (() => void) | undefined;
let rememberPromise: Promise<void> | undefined;
const updateStarted = new Promise<void>((resolve) => {
const updateKeys = async (updated: SSHKey[]) => {
assert.equal(currentKeys[0].passphrase, "saved");
assert.equal(updated[0].passphrase, "saved");
resolve();
await new Promise<void>((release) => {
releaseUpdate = release;
});
};
rememberPromise = rememberKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved",
keys,
updateKeys,
setCurrentKeys: (updated) => {
currentKeys = updated;
},
});
});
await updateStarted;
assert.equal(currentKeys[0].passphrase, "saved");
releaseUpdate?.();
await rememberPromise;
});

View File

@@ -0,0 +1,69 @@
import type { SftpFilenameEncoding } from "../../types";
export interface EditorSftpWrite {
(
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
sftpTabId?: string,
): Promise<string>;
}
// `useSftpState` is instantiated in at least two places (the top-level SftpView
// and the per-terminal SftpSidePanel), each owning its own pane registry. An
// editor tab opened from either path must be saved via the matching instance,
// so the bridge tracks all currently-mounted writers and dispatches by
// attempting each in turn until one succeeds.
//
// Each writer throws synchronously (or rejects) if the connectionId isn't in
// its pane registry; we use "connection no longer available" text as the
// signal to fall through to the next writer. Any other error is re-thrown
// immediately because it represents a real save failure the user must see.
const writers = new Set<EditorSftpWrite>();
const NOT_MY_CONNECTION_RE = /SFTP connection is no longer available/i;
export const registerEditorSftpWriter = (fn: EditorSftpWrite | null) => {
// Pass `null` on cleanup — but cleanup also needs to know WHICH writer to
// remove. Callers who register once per mount should instead use
// `registerEditorSftpWriterScoped` below, which returns an unregister fn.
// This legacy signature is preserved for callers that prefer the
// register/unregister-with-null pattern: we clear ALL writers on null.
if (fn === null) {
writers.clear();
return;
}
writers.add(fn);
};
export const registerEditorSftpWriterScoped = (fn: EditorSftpWrite): (() => void) => {
writers.add(fn);
return () => {
writers.delete(fn);
};
};
export const editorSftpWrite: EditorSftpWrite = async (...args) => {
if (writers.size === 0) {
throw new Error("SFTP editor bridge not registered — cannot save (no SFTP view mounted)");
}
let lastNotMine: Error | null = null;
for (const fn of writers) {
try {
return await fn(...args);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (NOT_MY_CONNECTION_RE.test(msg)) {
// This writer doesn't own the connectionId — try the next one.
lastNotMine = err instanceof Error ? err : new Error(msg);
continue;
}
// Real save error — surface it.
throw err;
}
}
// No writer owned the connectionId.
throw lastNotMine ?? new Error("SFTP connection is no longer available");
};

View File

@@ -0,0 +1,226 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
import { createEditorTabSaveService } from "./editorTabSave.ts";
import {
isBrowseSessionInteractive,
shouldParkBrowseSessions,
takeBrowseSessionsForClose,
} from "./sftp/browseSessionLifecycle.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/tmp/file.txt",
fileName: "file.txt",
languageId: "plaintext",
content: "v1",
baselineContent: "old",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("editor tab save service joins duplicate saves for the same content", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const pending = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await pending.promise;
return "conn_1";
},
});
const first = service.saveTab("edt_1");
const second = service.saveTab("edt_1", "v1");
assert.deepEqual(writes, ["v1"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(writes, ["v1"]);
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
assert.equal(store.getTab("edt_1")?.savingState, "idle");
});
test("editor tab save service queues newer tab content after an in-flight save", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const firstSave = deferred();
const secondSave = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
return "conn_1";
},
});
const first = service.saveTab("edt_1");
store.updateContent("edt_1", "v2", null);
const second = service.saveTab("edt_1");
assert.deepEqual(writes, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(writes, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
assert.equal(store.getTab("edt_1")?.content, "v2");
});
test("promoted side-panel editor saves while hidden and releases its session after close", async () => {
const store = new EditorTabStore();
const tabId = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/tmp/script.sh",
fileName: "script.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
const browseSessions = new Map([["conn_1", "sftp_1"]]);
const ownedSessionIds = new Set(["conn_1"]);
const ownedSftpTabIds = new Set(["pane_1"]);
const applyHiddenLifecycle = () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: store.hasOwnedEditorForSftpOwner({
sessionIds: ownedSessionIds,
sftpTabIds: ownedSftpTabIds,
}),
});
if (shouldParkBrowseSessions({ interactive, browseParked: false })) {
takeBrowseSessionsForClose(browseSessions);
}
};
applyHiddenLifecycle();
assert.equal(browseSessions.get("conn_1"), "sftp_1");
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (connectionId, _hostId, _remotePath, content) => {
assert.equal(browseSessions.has(connectionId), true);
writes.push(content);
return connectionId;
},
});
assert.equal(await service.saveTab(tabId), true);
assert.deepEqual(writes, ["echo changed"]);
store.close(tabId);
applyHiddenLifecycle();
assert.equal(browseSessions.size, 0);
});
test("hidden panel stays interactive while browse reconnects before session remap", () => {
const store = new EditorTabStore();
store.promoteFromModal({
sessionId: "conn_old",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/tmp/script.sh",
fileName: "script.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
const browseSessions = new Map([["conn_new", "sftp_1"]]);
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: store.hasOwnedEditorForSftpOwner({
sessionIds: new Set(["conn_new"]),
sftpTabIds: new Set(["pane_1"]),
}),
});
assert.equal(interactive, true);
assert.equal(
shouldParkBrowseSessions({ interactive, browseParked: false }),
false,
);
assert.equal(browseSessions.get("conn_new"), "sftp_1");
});
test("editor tab save remaps stale session ids returned by the SFTP writer", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
const seenConnectionIds: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (connectionId, _hostId, _remotePath, content, _encoding, sftpTabId) => {
seenConnectionIds.push(connectionId);
assert.equal(sftpTabId, "pane_1");
if (content === "next") {
assert.equal(connectionId, "conn_old");
store.remapSessionId("conn_old", "conn_new");
return "conn_new";
}
assert.equal(connectionId, "conn_new");
assert.equal(content, "next2");
return "conn_new";
},
});
assert.equal(await service.saveTab("edt_1", "next"), true);
assert.equal(store.getTab("edt_1")?.sessionId, "conn_new");
store.updateContent("edt_1", "next2", null);
assert.equal(await service.saveTab("edt_1"), true);
assert.deepEqual(seenConnectionIds, ["conn_old", "conn_new"]);
});
test("closing an SFTP pane prompts for dirty editors after reconnect id churn", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({
sessionId: "conn_old",
sftpTabId: "pane_1",
content: "dirty",
baselineContent: "clean",
}));
let prompted = false;
const ok = await store.confirmCloseByOwner(
{ sessionId: "conn_new", sftpTabId: "pane_1" },
async () => {
prompted = true;
return "discard";
},
);
assert.equal(prompted, true);
assert.equal(ok, true);
assert.equal(store.getTabs().length, 0);
});

View File

@@ -0,0 +1,82 @@
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from "./textEditorSaveCoordinator";
interface EditorTabSaveServiceDeps {
store: EditorTabStore;
write: EditorSftpWrite;
}
export interface EditorTabSaveService {
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
releaseTab(id: EditorTabId): void;
}
const formatSaveError = (error: unknown): string =>
error instanceof Error ? error.message : "Save failed";
export const createEditorTabSaveService = ({
store,
write,
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
const existing = coordinators.get(id);
if (existing) return existing;
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
const tab = store.getTab(id);
if (!tab) throw new Error("Editor tab closed before save completed");
const liveConnectionId = await write(
tab.sessionId,
tab.hostId,
tab.remotePath,
content,
undefined,
tab.sftpTabId,
);
if (liveConnectionId !== tab.sessionId) {
store.remapSessionId(tab.sessionId, liveConnectionId);
}
},
onSaveStart: () => {
store.setSavingState(id, "saving");
},
onSaveSuccess: (content) => {
store.markSaved(id, content);
},
onSaveError: (error) => {
store.setSavingState(id, "error", formatSaveError(error));
},
});
coordinators.set(id, coordinator);
return coordinator;
};
return {
saveTab: async (id, contentOverride) => {
const tab = store.getTab(id);
if (!tab) return false;
return getCoordinator(id).save(contentOverride ?? tab.content);
},
releaseTab: (id) => {
const coordinator = coordinators.get(id);
coordinator?.reset();
coordinators.delete(id);
},
};
};
const editorTabSaveService = createEditorTabSaveService({
store: editorTabStore,
write: editorSftpWrite,
});
export const saveEditorTab = editorTabSaveService.saveTab;
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;

View File

@@ -0,0 +1,370 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
editorTabStore,
EditorTabStore,
useHasEditorTabForSessions,
type EditorTab,
} from "./editorTabStore.ts";
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "worker_processes auto;",
baselineContent: "worker_processes auto;",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.updateContent("edt_1", "worker_processes 4;", null);
const tab = store.getTab("edt_1")!;
assert.equal(tab.content, "worker_processes 4;");
assert.equal(store.isDirty("edt_1"), true);
});
test("markSaved moves baseline to current content and clears dirty", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
assert.equal(store.isDirty("edt_1"), true);
store.markSaved("edt_1", "changed");
assert.equal(store.isDirty("edt_1"), false);
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
});
test("setWordWrap updates only that tab", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.setWordWrap("edt_1", true);
assert.equal(store.getTab("edt_1")!.wordWrap, true);
assert.equal(store.getTab("edt_2")!.wordWrap, false);
});
test("setSavingState transitions and clears error on idle", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.setSavingState("edt_1", "saving");
assert.equal(store.getTab("edt_1")!.savingState, "saving");
store.setSavingState("edt_1", "error", "EACCES");
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
store.setSavingState("edt_1", "idle");
assert.equal(store.getTab("edt_1")!.saveError, null);
});
test("close removes the tab and returns remaining ids in order", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.close("edt_1");
assert.equal(store.getTab("edt_1"), undefined);
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
});
test("subscribers fire on change and not on read", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
let count = 0;
const unsub = store.subscribe(() => { count++; });
store.getTab("edt_1");
store.getTabs();
assert.equal(count, 0);
store.updateContent("edt_1", "x", null);
// notifications are microtask-deferred, flush via awaiting a resolved promise
return Promise.resolve().then(() => {
assert.equal(count, 1);
unsub();
});
});
test("promoteFromModal creates a new tab and returns its id", () => {
const store = new EditorTabStore();
const id = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "x",
baselineContent: "x",
wordWrap: false,
viewState: null,
});
const tab = store.getTab(id)!;
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
assert.equal(tab.fileName, "nginx.conf");
assert.equal(tab.kind, "editor");
});
test("hasTabForSessions identifies the SFTP owner of a promoted editor", () => {
const store = new EditorTabStore();
store.promoteFromModal({
sessionId: "conn_owned",
sftpTabId: "pane_owned",
hostId: "host_1",
remotePath: "/tmp/script.sh",
fileName: "script.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
assert.equal(store.hasTabForSessions(new Set(["conn_owned"])), true);
assert.equal(store.hasTabForSessions(new Set(["conn_other"])), false);
});
test("useHasEditorTabForSessions updates when the owning editor opens and closes", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const ownedSessionIdsRef = { current: new Set(["conn_hook_test"]) };
const getOwnedSessionIds = () => ownedSessionIdsRef.current;
let hasOwnedEditorTab = false;
const Probe = () => {
hasOwnedEditorTab = useHasEditorTabForSessions(getOwnedSessionIds);
return null;
};
let renderer: ReactTestRenderer | null = null;
let tabId: string | null = null;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(hasOwnedEditorTab, false);
await act(async () => {
tabId = editorTabStore.promoteFromModal({
sessionId: "conn_hook_test",
sftpTabId: "pane_hook_test",
hostId: "host_1",
remotePath: "/tmp/hook-test.sh",
fileName: "hook-test.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
await Promise.resolve();
});
assert.equal(hasOwnedEditorTab, true);
await act(async () => {
editorTabStore.close(tabId!);
await Promise.resolve();
});
assert.equal(hasOwnedEditorTab, false);
} finally {
if (tabId) editorTabStore.close(tabId);
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
const store = new EditorTabStore();
const first = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/./nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v1",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
const second = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v2",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
assert.equal(second, first);
assert.equal(store.getTab(first)!.content, "v2");
assert.equal(store.getTabs().length, 1);
});
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
const store = new EditorTabStore();
const a = store.promoteFromModal({
sessionId: "conn_A",
sftpTabId: "pane_a",
hostId: "host_1",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
const b = store.promoteFromModal({
sessionId: "conn_B",
sftpTabId: "pane_b",
hostId: "host_2",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
assert.notEqual(a, b);
assert.equal(store.getTabs().length, 2);
});
test("confirmCloseBySession returns true when no tabs match", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 1);
});
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 0);
});
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
let prompts = 0;
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
assert.equal(ok, false);
assert.equal(prompts, 1, "prompt fires only for dirty tab");
// clean tab was closed before the dirty cancel aborted the batch
assert.equal(store.getTab("edt_clean"), undefined);
assert.ok(store.getTab("edt_dirty"));
});
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
let saved = false;
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
assert.equal(id, "edt_1");
saved = true;
store.markSaved(id, "new");
});
assert.equal(saved, true);
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});
test("remapSessionId updates editor ownership after browse reconnect", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old" }));
assert.equal(store.hasTabForSessions(new Set(["conn_old"])), true);
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), false);
const before = store.getPresenceRevision();
store.remapSessionId("conn_old", "conn_new");
assert.equal(store.getTab("edt_1")?.sessionId, "conn_new");
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), true);
assert.equal(store.getPresenceRevision(), before + 1);
});
test("hasOwnedEditorForSftpOwner keeps ownership via pane tab id during reconnect gap", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), false);
assert.equal(
store.hasOwnedEditorForSftpOwner({
sessionIds: new Set(["conn_new"]),
sftpTabIds: new Set(["pane_1"]),
}),
true,
);
});
test("confirmCloseByOwner matches editors by stable SFTP pane tab id", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({
sessionId: "conn_old",
sftpTabId: "pane_1",
content: "dirty",
baselineContent: "clean",
}));
let prompted = false;
const ok = await store.confirmCloseByOwner(
{ sessionId: "conn_new", sftpTabId: "pane_1" },
async () => {
prompted = true;
return "cancel";
},
);
assert.equal(prompted, true);
assert.equal(ok, false);
assert.equal(store.getTabs().length, 1);
});
test("forceCloseByOwners closes editors matched by SFTP pane tab id", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
const closed = store.forceCloseByOwners({ sftpTabIds: ["pane_1"] });
assert.deepEqual(closed, ["edt_1"]);
assert.equal(store.getTabs().length, 0);
});
test("updateContent does not bump editor presence revision", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const before = store.getPresenceRevision();
store.updateContent("edt_1", "changed", null);
assert.equal(store.getPresenceRevision(), before);
});

View File

@@ -0,0 +1,395 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
const normalizePath = (p: string): string => {
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
};
export type EditorTabId = string;
export type EditorSavingState = "idle" | "saving" | "error";
export interface EditorTab {
id: EditorTabId;
kind: "editor";
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
sessionId: string;
/** Stable SFTP pane tab id — survives browse reconnects that regenerate connection ids. */
sftpTabId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
}
type Listener = () => void;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
export class EditorTabStore {
private tabs: EditorTab[] = [];
private listeners = new Set<Listener>();
private presenceListeners = new Set<Listener>();
private pendingNotify = false;
private pendingPresenceNotify = false;
private presenceRevision = 0;
getTabs = (): readonly EditorTab[] => this.tabs;
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
hasTabForSessions = (sessionIds: ReadonlySet<string>): boolean =>
this.tabs.some((tab) => sessionIds.has(tab.sessionId));
hasTabForSftpTabIds = (sftpTabIds: ReadonlySet<string>): boolean =>
this.tabs.some((tab) => sftpTabIds.has(tab.sftpTabId));
/** Match promoted editors by stable pane tab id and/or live connection id. */
hasOwnedEditorForSftpOwner = (params: {
sessionIds: ReadonlySet<string>;
sftpTabIds: ReadonlySet<string>;
}): boolean =>
this.tabs.some((tab) =>
params.sftpTabIds.has(tab.sftpTabId) || params.sessionIds.has(tab.sessionId),
);
getPresenceRevision = (): number => this.presenceRevision;
/** Update editor tabs after browse reconnect replaces a connection id. */
remapSessionId = (fromSessionId: string, toSessionId: string): void => {
if (fromSessionId === toSessionId) return;
let changed = false;
this.tabs = this.tabs.map((tab) => {
if (tab.sessionId !== fromSessionId) return tab;
changed = true;
return { ...tab, sessionId: toSessionId };
});
if (changed) this.notifyStructural();
};
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && t.content !== t.baselineContent;
};
updateContent = (
id: EditorTabId,
content: string,
viewState: Monaco.editor.ICodeEditorViewState | null,
) => {
this.patch(id, { content, viewState });
};
markSaved = (id: EditorTabId, newBaseline: string) => {
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
setWordWrap = (id: EditorTabId, value: boolean) => {
this.patch(id, { wordWrap: value });
};
setLanguage = (id: EditorTabId, languageId: string) => {
this.patch(id, { languageId });
};
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
const patch: Partial<EditorTab> = { savingState: state };
if (state === "idle") patch.saveError = null;
else if (state === "error") patch.saveError = error;
this.patch(id, patch);
};
close = (id: EditorTabId) => {
const next = this.tabs.filter((t) => t.id !== id);
if (next.length !== this.tabs.length) {
this.tabs = next;
this.notifyStructural();
}
};
/**
* Force-close every tab bound to any of the given sessionIds, with no dirty
* prompt. Intended for cases where the owning SFTP instance has gone away
* entirely (e.g. the hosting terminal tab was closed) and there is no
* realistic save channel anyway. Returns the closed tab ids.
*/
private tabMatchesOwner = (
tab: EditorTab,
owner: { sessionId?: string; sftpTabId?: string },
): boolean =>
(owner.sessionId != null && tab.sessionId === owner.sessionId)
|| (owner.sftpTabId != null && tab.sftpTabId === owner.sftpTabId);
/**
* Force-close every tab bound to any owner id, with no dirty prompt.
* Matches by live connection id and/or stable SFTP pane tab id.
*/
forceCloseByOwners = (owners: {
sessionIds?: readonly string[];
sftpTabIds?: readonly string[];
}): EditorTabId[] => {
const sessionSet = new Set(owners.sessionIds ?? []);
const tabIdSet = new Set(owners.sftpTabIds ?? []);
if (sessionSet.size === 0 && tabIdSet.size === 0) return [];
const removed = this.tabs
.filter((t) => sessionSet.has(t.sessionId) || tabIdSet.has(t.sftpTabId))
.map((t) => t.id);
if (removed.length === 0) return [];
const removedSet = new Set(removed);
this.tabs = this.tabs.filter((t) => !removedSet.has(t.id));
this.notifyStructural();
const activeId = activeTabStore.getActiveTabId();
if (isEditorTabId(activeId)) {
const activeEditorId = fromEditorTabId(activeId);
if (activeEditorId && removed.includes(activeEditorId)) {
activeTabStore.setActiveTabId('vault');
}
}
return removed;
};
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] =>
this.forceCloseByOwners({ sessionIds });
promoteFromModal = (snapshot: {
sessionId: string;
sftpTabId: string;
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
}
const tab: EditorTab = {
id: this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
return tab.id;
};
/**
* Walk editor tabs owned by a connection id and/or SFTP pane tab id. Clean tabs
* close silently; dirty tabs prompt via `promptChoice`. 'save' invokes `saveTab`
* and closes only on its success. Any 'cancel' aborts the batch and returns false.
*/
confirmCloseByOwner = async (
owner: { sessionId?: string; sftpTabId?: string },
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
await saveTab(tab.id);
} catch {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}
return true;
};
confirmCloseBySession = async (
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> =>
this.confirmCloseByOwner({ sessionId }, promptChoice, saveTab, onCloseTab);
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
/** Tab open/close/session remap only — not editor content or save-state churn. */
subscribePresence = (listener: Listener): (() => void) => {
this.presenceListeners.add(listener);
return () => { this.presenceListeners.delete(listener); };
};
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
_debugInsert = (tab: EditorTab) => {
this.tabs = [...this.tabs, tab];
this.notifyStructural();
};
protected makeId = genId;
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
let changed = false;
this.tabs = this.tabs.map((t) => {
if (t.id !== id) return t;
changed = true;
return { ...t, ...patch };
});
if (changed) this.notifyContent();
};
protected notifyContent = () => {
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach((l) => l());
});
};
protected notifyStructural = () => {
this.presenceRevision += 1;
this.notifyPresence();
this.notifyContent();
};
protected notifyPresence = () => {
if (this.pendingPresenceNotify) return;
this.pendingPresenceNotify = true;
Promise.resolve().then(() => {
this.pendingPresenceNotify = false;
this.presenceListeners.forEach((l) => l());
});
};
}
export const editorTabStore = new EditorTabStore();
// Hooks
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
/**
* Chrome-only editor tab fields for App shell / TopTabs ordering.
* Content/save-state churn must not flow through this list.
*/
export type EditorTabChrome = Pick<
EditorTab,
| 'id'
| 'kind'
| 'sessionId'
| 'sftpTabId'
| 'hostId'
| 'remotePath'
| 'fileName'
| 'languageId'
>;
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
id: tab.id,
kind: tab.kind,
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
});
export const useHasEditorTabForSessions = (
getSessionIds: () => ReadonlySet<string>,
): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.hasTabForSessions(getSessionIds()),
[getSessionIds],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};
/** Re-render only when editor tabs open/close or their SFTP session binding changes. */
export const useEditorTabPresenceRevision = (): number =>
useSyncExternalStore(
editorTabStore.subscribePresence,
() => editorTabStore.getPresenceRevision(),
() => editorTabStore.getPresenceRevision(),
);
/**
* Subscribe to open/close/remap only. Safe for App domain memos and tab strip
* structure; dirty dots and Monaco content must use per-tab hooks.
*/
export const useEditorTabChromeList = (): readonly EditorTabChrome[] => {
const revision = useEditorTabPresenceRevision();
return useMemo(() => {
void revision;
return editorTabStore.getTabs().map(projectEditorTabChrome);
}, [revision]);
};
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};
/**
* Per-tab dirty flag. Content edits notify the store, but React skips re-render
* when this tab's dirty boolean is unchanged (Object.is).
*/
export const useEditorTabDirty = (id: EditorTabId): boolean =>
useSyncExternalStore(
editorTabStore.subscribe,
() => editorTabStore.isDirty(id),
() => editorTabStore.isDirty(id),
);

View File

@@ -0,0 +1,9 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import test from 'node:test';
const source = readFileSync(new URL('./fontStore.ts', import.meta.url), 'utf8');
test('refresh clears local font and availability detection caches', () => {
assert.match(source, /clearLocalFontsCache\(\);\s*clearFontAvailabilityCache\(\);/);
});

View File

@@ -0,0 +1,185 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import {
clearLocalFontsCache,
getAllSystemFontFamilyNames,
getMonospaceFonts,
} from '../../lib/localFonts';
import {
clearFontAvailabilityCache,
setSystemFamilies,
} from '../../lib/fontAvailability';
/**
* Global font store - singleton pattern using useSyncExternalStore
* Ensures fonts are loaded only once and shared across all components
*/
type Listener = () => void;
interface FontStoreState {
availableFonts: TerminalFont[];
installedFontFamilies: string[] | null;
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
class FontStore {
private state: FontStoreState = {
availableFonts: TERMINAL_FONTS,
installedFontFamilies: null,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
// Getters for individual state slices
getAvailableFonts = (): TerminalFont[] => this.state.availableFonts;
getInstalledFontFamilies = (): string[] | null => this.state.installedFontFamilies;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
getError = (): string | null => this.state.error;
private notify = () => {
// Defer listener notification to avoid "setState during render"
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<FontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
/**
* Initialize font loading - safe to call multiple times,
* will only load once
*/
initialize = async (): Promise<void> => {
// Already loaded or currently loading
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
// Populate the authoritative installed-family set used by
// fontAvailability.isFontInstalled. Runs in parallel with the
// monospace-only query (both share an underlying cache).
const [localFonts, installedFontFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilyNames(),
]);
setSystemFamilies(
installedFontFamilies
? new Set(installedFontFamilies.map((family) => family.toLowerCase()))
: null,
);
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();
// Add default fonts first
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
// Build a set of built-in font family names for dedup (case-insensitive)
const builtinFamilyNames = new Set(
TERMINAL_FONTS.map(f => f.name.toLowerCase())
);
// Add local fonts, skipping those already covered by built-in fonts
localFonts.forEach(font => {
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
fontMap.set(localId, { ...font, id: localId });
});
this.setState({
availableFonts: Array.from(fontMap.values()),
installedFontFamilies,
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local fonts, using defaults:', error);
this.setState({
availableFonts: TERMINAL_FONTS,
installedFontFamilies: null,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
refresh = async (): Promise<void> => {
if (this.state.isLoading) return;
clearLocalFontsCache();
clearFontAvailabilityCache();
this.setState({ isLoaded: false });
await this.initialize();
};
/**
* Find a font by ID with fallback
*/
getFontById = (fontId: string): TerminalFont => {
const fonts = this.state.availableFonts;
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
}
// Singleton instance
export const fontStore = new FontStore();
// ============== Hooks ==============
/**
* Get available fonts - triggers initialization on first use
*/
export const useAvailableFonts = (): TerminalFont[] => {
// Trigger initialization on first use
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
fontStore.initialize();
}
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getAvailableFonts,
fontStore.getAvailableFonts,
);
};
export const useInstalledFontFamilies = (): string[] | null => {
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
fontStore.initialize();
}
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getInstalledFontFamilies,
fontStore.getInstalledFontFamilies,
);
};
export const useFontsLoading = (): boolean => useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading,
);
export const refreshFonts = (): Promise<void> => fontStore.refresh();
/**
* Initialize fonts eagerly (call at app startup)
*/
export const initializeFonts = (): void => {
fontStore.initialize();
};

View File

@@ -0,0 +1,36 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class HostTreeInlineGroupDeleteStore {
private targetPath: string | null = null;
private listeners = new Set<Listener>();
getTargetPath = () => this.targetPath;
open = (groupPath: string) => {
this.targetPath = groupPath;
this.listeners.forEach((listener) => listener());
};
close = () => {
if (!this.targetPath) return;
this.targetPath = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
export const useHostTreeInlineGroupDeleteTarget = () => {
return useSyncExternalStore(
hostTreeInlineGroupDeleteStore.subscribe,
hostTreeInlineGroupDeleteStore.getTargetPath,
hostTreeInlineGroupDeleteStore.getTargetPath,
);
};

View File

@@ -0,0 +1,52 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineGroupEdit = {
groupPath: string;
initialName: string;
isNew: boolean;
shouldScrollIntoView?: boolean;
};
type Listener = () => void;
class HostTreeInlineGroupEditStore {
private edit: HostTreeInlineGroupEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineGroupEdit) => {
this.edit = {
...edit,
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
};
this.listeners.forEach((listener) => listener());
};
markScrollHandled = () => {
if (!this.edit?.shouldScrollIntoView) return;
this.edit = { ...this.edit, shouldScrollIntoView: false };
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
export const useHostTreeInlineGroupEdit = () => {
return useSyncExternalStore(
hostTreeInlineGroupEditStore.subscribe,
hostTreeInlineGroupEditStore.getEdit,
hostTreeInlineGroupEditStore.getEdit,
);
};

View File

@@ -0,0 +1,41 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineHostEdit = {
hostId: string;
initialName: string;
};
type Listener = () => void;
class HostTreeInlineHostEditStore {
private edit: HostTreeInlineHostEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineHostEdit) => {
this.edit = edit;
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
export const useHostTreeInlineHostEdit = () => {
return useSyncExternalStore(
hostTreeInlineHostEditStore.subscribe,
hostTreeInlineHostEditStore.getEdit,
hostTreeInlineHostEditStore.getEdit,
);
};

View File

@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { captureInheritedCwd } from "./inheritedCwd";
const neverProbe = async () => { throw new Error("should not probe"); };
test("live tracked cwd wins over everything and skips the probe", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/stale" },
neverProbe,
{ liveCwd: "/live/tracked" },
);
assert.equal(cwd, "/live/tracked");
});
test("connected ssh probes live cwd when no tracked cwd, ignoring stale lastCwd", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/stale" },
async () => ({ success: true, cwd: "/probed" }),
);
assert.equal(cwd, "/probed");
});
test("connected ssh does NOT probe when allowSshProbe is false (network device)", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/a" },
neverProbe,
{ allowSshProbe: false },
);
assert.equal(cwd, "/a");
});
test("connected ssh falls back to lastCwd when probe reports failure", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/a" },
async () => ({ success: false }),
);
assert.equal(cwd, "/a");
});
test("connected ssh falls back to lastCwd when probe throws", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/a" },
async () => { throw new Error("boom"); },
);
assert.equal(cwd, "/a");
});
test("connected ssh with no lastCwd and failed probe -> undefined", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected" },
async () => ({ success: false }),
);
assert.equal(cwd, undefined);
});
test("connected ssh falls back to lastCwd when probe exceeds the timeout", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected", lastCwd: "/a" },
() => new Promise(() => { /* never resolves */ }),
{ probeTimeoutMs: 10 },
);
assert.equal(cwd, "/a");
});
test("connected ssh passes its probe timeout to the backend", async () => {
let receivedOptions: { allowHomeFallback?: boolean; timeoutMs?: number } | undefined;
await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "connected" },
async (_sessionId, options) => {
receivedOptions = options;
return { success: false };
},
{ probeTimeoutMs: 1234 },
);
assert.deepEqual(receivedOptions, { allowHomeFallback: false, timeoutMs: 1234 });
});
test("disconnected ssh uses lastCwd without probing", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "ssh", status: "disconnected", lastCwd: "/a" },
neverProbe,
);
assert.equal(cwd, "/a");
});
test("local uses live tracked cwd when present (no probe)", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "local", status: "connected", localStartDir: "/home/u" },
neverProbe,
{ liveCwd: "/home/u/project" },
);
assert.equal(cwd, "/home/u/project");
});
test("local without live cwd or lastCwd falls back to localStartDir (no probe)", async () => {
const cwd = await captureInheritedCwd(
{ id: "s", protocol: "local", status: "connected", localStartDir: "/home/u" },
neverProbe,
);
assert.equal(cwd, "/home/u");
});

View File

@@ -0,0 +1,87 @@
import type { TerminalSession } from "../../domain/models";
export type SessionPwdProbe = (
sessionId: string,
options?: {
allowHomeFallback?: boolean;
allowLoginShellFallback?: boolean;
timeoutMs?: number;
},
) => Promise<{ success: boolean; cwd?: string }>;
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
export interface CaptureInheritedCwdOptions {
/**
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
* cwd map rather than the session object. This is the freshest value and the
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
* never mirrored onto `TerminalSession.lastCwd`).
*/
liveCwd?: string;
/**
* Whether an SSH `/proc` probe is permitted. Callers pass `false` for network
* devices (e.g. Huawei VRP), where the extra exec channel can drop the whole
* session — mirrors `shouldProbeSessionCwd` in the terminal cwd-probe path.
*/
allowSshProbe?: boolean;
/** Max time to wait on the probe before falling back. */
probeTimeoutMs?: number;
}
/** Max time to wait on the live SSH cwd probe before falling back to lastCwd. */
export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
/**
* Resolve the working directory a clone/split should inherit from its source.
*
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
* against a short timeout so a slow/wedged connection can't block tab creation,
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when
* nothing is known (caller then behaves as before: login dir).
*/
export async function captureInheritedCwd(
session: CaptureSession,
getSessionPwd: SessionPwdProbe,
options: CaptureInheritedCwdOptions = {},
): Promise<string | undefined> {
const {
liveCwd,
allowSshProbe = true,
probeTimeoutMs = DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS,
} = options;
const live = liveCwd?.trim();
if (live) return live;
const protocol = session.protocol ?? "ssh";
const isRemoteSsh = protocol === "ssh" || protocol === undefined;
if (isRemoteSsh && allowSshProbe && session.status === "connected") {
// Never rejects: a failed/absent probe resolves to undefined so the race
// below can't leave a dangling unhandled rejection when the timeout wins.
const probePromise = getSessionPwd(session.id, {
allowHomeFallback: false,
// Keep the backend exec within the same budget as this UI-side timeout.
timeoutMs: probeTimeoutMs,
})
.then((res) => (res?.success ? res.cwd?.trim() : undefined))
.catch(() => undefined);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), probeTimeoutMs);
});
const probed = await Promise.race([probePromise, timeoutPromise]);
if (timer) clearTimeout(timer);
if (probed) return probed;
}
const tracked = session.lastCwd?.trim();
if (tracked) return tracked;
if (protocol === "local") return session.localStartDir;
return undefined;
}

View File

@@ -0,0 +1,24 @@
import type { ConnectionLog } from "../../domain/models";
export interface LogView {
id: string;
connectionLogId: string;
log: ConnectionLog;
}
export const getLogViewTabId = (log: Pick<ConnectionLog, "id">): string => `log-${log.id}`;
export const addLogView = (views: LogView[], log: ConnectionLog): LogView[] => {
if (views.some((view) => view.connectionLogId === log.id)) return views;
return [
...views,
{
id: getLogViewTabId(log),
connectionLogId: log.id,
log,
},
];
};
export const removeLogView = (views: LogView[], logViewId: string): LogView[] =>
views.filter((view) => view.id !== logViewId);

View File

@@ -0,0 +1,73 @@
import { STORAGE_KEY_NETWORK_DEVICE_SUGGEST_HANDLED } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
/**
* Persistence boundary for the "enable Network Device Mode" suggestion.
*
* The tip is suggested at most once per host. A host is recorded as handled
* either the first time the tip is *displayed* (so simply closing the session
* does not re-nag on every reconnect) or when the user explicitly
* enables/dismisses it. Displaying it is recorded *silently* (no listener
* notification) so the instance showing the tip keeps it until the user acts,
* while later-mounting instances for the same host are suppressed.
*
* State is stored under one key *per host* rather than a single shared array:
* localStorage has no atomic read-modify-write, so two windows appending to a
* shared array concurrently would drop one another's markers. Independent keys
* never clobber each other.
*/
const keyPrefix = `${STORAGE_KEY_NETWORK_DEVICE_SUGGEST_HANDLED}:`;
const keyForHost = (hostId: string): string => `${keyPrefix}${hostId}`;
export const isNetworkDeviceSuggestionHandled = (hostId: string): boolean =>
localStorageAdapter.readBoolean(keyForHost(hostId)) === true;
type HandledListener = (hostId: string) => void;
const listeners = new Set<HandledListener>();
/**
* Subscribe to handled-state changes for any host. The listener receives the
* host id that changed. Fires for in-process resolves (enable/dismiss) and for
* changes propagated from other renderer windows via the `storage` event.
*/
export const subscribeNetworkDeviceSuggestionHandled = (
listener: HandledListener,
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
const notify = (hostId: string): void => {
for (const listener of listeners) listener(hostId);
};
/**
* Record that the tip was shown for a host without notifying listeners, so the
* instance currently displaying it stays visible while future reconnects and
* later-mounting instances are suppressed.
*/
export const markNetworkDeviceSuggestionShown = (hostId: string): void => {
localStorageAdapter.writeBoolean(keyForHost(hostId), true);
};
/**
* Record an explicit enable/dismiss and notify listeners so any other pane or
* window still showing the tip for this host hides it too.
*/
export const resolveNetworkDeviceSuggestion = (hostId: string): void => {
localStorageAdapter.writeBoolean(keyForHost(hostId), true);
notify(hostId);
};
// Cross-window propagation: the native `storage` event fires in *other*
// same-origin windows (e.g. the detached `#/session-window` peer) when a key
// changes there. Per-host keys let us recover the host id directly.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', (event) => {
if (!event.key || !event.key.startsWith(keyPrefix)) return;
notify(event.key.slice(keyPrefix.length));
});
}

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
EMPTY_NOTES_SNAPSHOT,
getEmptyNotesSnapshot,
getNotesActions,
getNotesSnapshot,
publishNotesSnapshot,
registerNotesActions,
subscribeNotes,
subscribeNotesNoop,
} from './notesStore.ts';
test('notesStore notifies subscribers only when snapshot identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeNotes(() => {
events.push(getNotesSnapshot().notes.length);
});
const firstNotes = [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}];
const firstGroups = ['Ops'];
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 1);
assert.equal(getNotesSnapshot().notes, firstNotes);
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.length, 1);
const secondNotes = [
...firstNotes,
{
id: 'n2',
title: 'Two',
content: 'more',
tags: [],
createdAt: 2,
updatedAt: 2,
order: 2000,
},
];
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 2);
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
assert.equal(events.length, 3);
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
unsubscribe();
});
test('notesStore gated helpers stay empty and never notify', () => {
let called = 0;
const unsub = subscribeNotesNoop(() => {
called += 1;
});
publishNotesSnapshot({
notes: [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}],
noteGroups: ['Ops'],
});
assert.equal(called, 0);
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
unsub();
});
test('useNotesStore source gates subscribe when enabled is false', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
assert.match(source, /getNotesSnapshot/);
assert.doesNotMatch(
source,
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
'hidden notes mounts should keep the last live snapshot, not flash empty',
);
});
test('registerNotesActions exposes update handlers', () => {
const calls: string[] = [];
registerNotesActions({
updateNotes: () => {
calls.push('notes');
},
updateNoteGroups: () => {
calls.push('groups');
},
});
const actions = getNotesActions();
assert.ok(actions);
actions!.updateNotes([]);
actions!.updateNoteGroups([]);
assert.deepEqual(calls, ['notes', 'groups']);
registerNotesActions(null);
assert.equal(getNotesActions(), null);
});

View File

@@ -0,0 +1,143 @@
import { useSyncExternalStore } from 'react';
import type { VaultNote } from '../../domain/models';
type Listener = () => void;
export type NotesSnapshot = {
notes: readonly VaultNote[];
noteGroups: readonly string[];
};
export type NotesActions = {
updateNotes: (notes: VaultNote[]) => boolean | void;
updateNoteGroups: (groups: string[]) => void;
};
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
notes: EMPTY_NOTES,
noteGroups: EMPTY_NOTE_GROUPS,
});
/**
* External store for vault notes so Notes / AI side-panel consumers can
* subscribe without forcing TerminalLayer re-renders on every note edit.
*/
class NotesStore {
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
private actions: NotesActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): NotesSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: NotesSnapshot): void {
if (
this.snapshot.notes === next.notes
&& this.snapshot.noteGroups === next.noteGroups
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): NotesActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: NotesActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const notesStore = new NotesStore();
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
notesStore.setSnapshot(snapshot);
}
export function getNotesSnapshot(): NotesSnapshot {
return notesStore.getSnapshot();
}
export function subscribeNotes(listener: Listener): () => void {
return notesStore.subscribe(listener);
}
/** No-op subscribe for gated (hidden) panel mounts. */
export function subscribeNotesNoop(_listener: Listener): () => void {
return () => {};
}
export function getEmptyNotesSnapshot(): NotesSnapshot {
return EMPTY_NOTES_SNAPSHOT;
}
export function registerNotesActions(actions: NotesActions | null): void {
notesStore.setActions(actions);
}
export function getNotesActions(): NotesActions | null {
return notesStore.getActions();
}
export function subscribeNotesActions(listener: Listener): () => void {
return notesStore.subscribeActions(listener);
}
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
/**
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
*
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
* notes side panel) so vault note publishes do not re-render them. Snapshot
* reads still use the live store so reopen does not flash empty data.
*/
export function useNotesStore(options?: { enabled?: boolean }): {
notes: VaultNote[];
noteGroups: string[];
updateNotes: NotesActions['updateNotes'];
updateNoteGroups: NotesActions['updateNoteGroups'];
} {
const enabled = options?.enabled !== false;
const snapshot = useSyncExternalStore(
enabled ? subscribeNotes : subscribeNotesNoop,
getNotesSnapshot,
getNotesSnapshot,
);
const actions = useSyncExternalStore(
enabled ? subscribeNotesActions : subscribeNotesNoop,
getNotesActions,
getNotesActions,
);
return {
notes: snapshot.notes as VaultNote[],
noteGroups: snapshot.noteGroups as string[],
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
};
}

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
handleTerminalOscNotification,
showOscDesktopNotification,
} from "./oscDesktopNotifications.ts";
const installNotificationBridge = () => {
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
netcatty: {
showSystemNotification: async (payload) => {
calls.push(payload);
return { shown: true };
},
},
};
return {
calls,
restore() {
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
else (globalThis as { window: unknown }).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
},
};
};
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
const fixture = installNotificationBridge();
try {
showOscDesktopNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "", body: "quiet", protocol: "osc9" },
mode: "unfocused",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
assert.deepEqual(fixture.calls, [{
title: "Codex",
body: "Turn complete",
sessionId: "s1",
}]);
} finally {
fixture.restore();
}
});
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
const fixture = installNotificationBridge();
let activity = 0;
try {
assert.equal(handleTerminalOscNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s-off",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), false);
assert.equal(activity, 0);
assert.equal(fixture.calls.length, 0);
assert.equal(handleTerminalOscNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: false,
sessionId: "s-on",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), true);
assert.equal(activity, 1);
assert.equal(fixture.calls.length, 1);
} finally {
fixture.restore();
}
});

View File

@@ -0,0 +1,60 @@
import {
DEFAULT_OSC_NOTIFICATION_TITLE,
OscNotificationLimiter,
resolveOscNotificationPresentation,
shouldShowOscDesktopNotification,
type OscNotification,
} from "../../domain/terminalOscNotifications";
import type { OscNotificationMode } from "../../domain/models/terminal";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const sessionLimiters = new Map<string, OscNotificationLimiter>();
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
const existing = sessionLimiters.get(sessionId);
if (existing) return existing;
const limiter = new OscNotificationLimiter();
sessionLimiters.set(sessionId, limiter);
return limiter;
};
export function showOscDesktopNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
}): boolean {
if (!shouldShowOscDesktopNotification(options.mode, {
windowFocused: typeof document !== "undefined" && document.hasFocus(),
sessionFocused: options.sessionFocused,
})) {
return false;
}
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
const presented = resolveOscNotificationPresentation(
options.notification,
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
);
void netcattyBridge.get()?.showSystemNotification?.({
title: presented.title,
body: presented.body,
sessionId: options.sessionId,
});
return true;
}
export function handleTerminalOscNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
onSessionActivity?: () => void;
}): boolean {
if (options.mode === "off") return false;
options.onSessionActivity?.();
showOscDesktopNotification(options);
return true;
}

View File

@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildTerminalPluginContributionContext,
resolveActivePluginKeybindingContext,
} from './pluginContributionContexts.ts';
import type { TerminalSession, Workspace } from '../../types.ts';
const sessions: TerminalSession[] = [
{
id: 'session-1',
hostId: 'host-1',
hostLabel: 'Host 1',
username: 'root',
hostname: 'example.com',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
{
id: 'session-2',
hostId: 'local-2',
hostLabel: 'Local',
username: '',
hostname: 'localhost',
status: 'disconnected',
protocol: 'local',
},
];
const workspaces: Workspace[] = [{
id: 'workspace-1',
title: 'Workspace',
root: { id: 'pane-1', type: 'pane', sessionId: 'session-1' },
}];
test('builds a resource-bearing terminal command context', () => {
assert.deepEqual(buildTerminalPluginContributionContext({
surface: 'terminal/context',
sessionId: 'session-1',
status: 'connected',
hostId: 'host-1',
hostProtocol: 'ssh',
workspaceId: 'workspace-1',
hasSelection: true,
alternateScreen: false,
reconnectable: false,
}), {
'netcatty.surface': 'terminal/context',
'terminal.sessionId': 'session-1',
'terminal.status': 'connected',
'host.id': 'host-1',
'host.protocol': 'ssh',
'workspace.id': 'workspace-1',
'terminal.hasSelection': true,
'terminal.alternateScreen': false,
'terminal.reconnectable': false,
});
});
test('resolves the focused workspace session for global keybindings', () => {
assert.deepEqual(resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions,
workspaces,
}), {
'netcatty.surface': 'keybinding',
'terminal.sessionId': 'session-1',
'terminal.status': 'connected',
'host.id': 'host-1',
'host.protocol': 'ssh',
'workspace.id': 'workspace-1',
'netcatty.activeTabId': 'workspace-1',
});
});
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
assert.equal(
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
'session-2',
);
assert.deepEqual(resolveActivePluginKeybindingContext({
activeTabId: 'vault',
sessions,
workspaces,
}), {
'netcatty.surface': 'keybinding',
'netcatty.activeTabId': 'vault',
});
});

View File

@@ -0,0 +1,74 @@
import { collectSessionIds } from '../../domain/workspace';
import type { TerminalSession, Workspace } from '../../types';
export type PluginContributionContextValue = string | boolean | number | null;
export type PluginContributionContext = Record<string, PluginContributionContextValue>;
export interface TerminalPluginContributionContextOptions {
surface: 'terminal/context' | 'terminal/toolbar' | 'statusBar' | 'keybinding';
sessionId?: string;
status?: TerminalSession['status'];
hostId?: string;
hostProtocol?: string;
workspaceId?: string;
hasSelection?: boolean;
alternateScreen?: boolean;
reconnectable?: boolean;
}
export function buildTerminalPluginContributionContext({
surface,
sessionId,
status,
hostId,
hostProtocol,
workspaceId,
hasSelection,
alternateScreen,
reconnectable,
}: TerminalPluginContributionContextOptions): PluginContributionContext {
return {
'netcatty.surface': surface,
...(sessionId ? { 'terminal.sessionId': sessionId } : {}),
...(status ? { 'terminal.status': status } : {}),
...(hostId ? { 'host.id': hostId } : {}),
...(hostProtocol ? { 'host.protocol': hostProtocol } : {}),
...(workspaceId ? { 'workspace.id': workspaceId } : {}),
...(hasSelection === undefined ? {} : { 'terminal.hasSelection': hasSelection }),
...(alternateScreen === undefined ? {} : { 'terminal.alternateScreen': alternateScreen }),
...(reconnectable === undefined ? {} : { 'terminal.reconnectable': reconnectable }),
};
}
export function resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
}: {
activeTabId: string;
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): PluginContributionContext {
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
const activeSessionId = workspace
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
: sessions.some((candidate) => candidate.id === activeTabId)
? activeTabId
: undefined;
const session = activeSessionId
? sessions.find((candidate) => candidate.id === activeSessionId)
: undefined;
const workspaceId = workspace?.id ?? session?.workspaceId;
return {
...buildTerminalPluginContributionContext({
surface: 'keybinding',
sessionId: session?.id,
status: session?.status,
hostId: session?.hostId,
hostProtocol: session?.protocol ?? (session ? 'ssh' : undefined),
workspaceId,
}),
'netcatty.activeTabId': activeTabId,
};
}

View File

@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectPluginThemeTokens } from './pluginContributionEnvironment.ts';
test('plugin theme tokens come from the themed app surface style', () => {
assert.deepEqual(selectPluginThemeTokens({
'--background': '220 10% 10%',
'--foreground': '0 0% 98%',
'--primary': '210 90% 55%',
colorScheme: 'dark',
'--unknown-plugin-token': 'ignored',
}), {
'--background': '220 10% 10%',
'--foreground': '0 0% 98%',
'--primary': '210 90% 55%',
});
});

View File

@@ -0,0 +1,19 @@
export const PLUGIN_THEME_TOKEN_NAMES = Object.freeze([
'--background',
'--foreground',
'--muted',
'--muted-foreground',
'--border',
'--primary',
'--primary-foreground',
] as const);
export function selectPluginThemeTokens(
source: Readonly<Record<string, unknown>> | undefined,
): Record<string, string> {
if (!source) return {};
return Object.fromEntries(PLUGIN_THEME_TOKEN_NAMES.flatMap((name) => {
const value = source[name];
return typeof value === 'string' && value.length > 0 ? [[name, value]] : [];
}));
}

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import { pluginExtensionBridge } from "./pluginExtensionBridge";
const setBridge = (bridge: Partial<NetcattyBridge> | undefined) => {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: bridge },
});
};
test("plugin event subscriptions are inert when the desktop bridge is absent", () => {
setBridge(undefined);
const stopImporter = pluginExtensionBridge.onImporterProgress(() => {});
const stopAuthentication = pluginExtensionBridge.onAuthenticationChallenge(() => {});
const stopContributions = pluginExtensionBridge.onContributionsChanged(() => {});
assert.equal(typeof stopImporter, "function");
assert.equal(typeof stopAuthentication, "function");
assert.equal(typeof stopContributions, "function");
stopImporter();
stopAuthentication();
stopContributions();
});
test("plugin contribution change subscriptions forward desktop bridge events", () => {
let subscribed: (() => void) | null = null;
let disposed = false;
setBridge({
onPluginContributionsChanged: (listener) => {
subscribed = listener;
return () => { disposed = true; };
},
});
let observed = 0;
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => { observed += 1; });
subscribed?.();
assert.equal(observed, 1);
unsubscribe();
assert.equal(disposed, true);
});
test("plugin credential options follow only a successfully accepted secure catalog", async () => {
const published: Array<ReadonlyArray<string>> = [];
const unsubscribe = pluginExtensionBridge.subscribeCredentialCatalog(() => {
published.push(pluginExtensionBridge.getCredentialCatalogIds());
});
setBridge({
updatePluginCredentialCatalog: async (entries) => entries.length,
});
const entries = [
{ id: "credential-reference-0001", ciphertext: "enc:v1:Y2lwaGVy" },
{ id: "credential-reference-0002", ciphertext: "enc:v1:Y2lwaGVy" },
];
assert.equal(await pluginExtensionBridge.updateCredentialCatalog(entries), 2);
assert.deepEqual(pluginExtensionBridge.getCredentialCatalogIds(), entries.map((entry) => entry.id));
assert.equal(Object.isFrozen(pluginExtensionBridge.getCredentialCatalogIds()), true);
setBridge({});
assert.equal(await pluginExtensionBridge.updateCredentialCatalog(entries), 0);
assert.deepEqual(pluginExtensionBridge.getCredentialCatalogIds(), []);
assert.deepEqual(published, [
entries.map((entry) => entry.id),
[],
]);
setBridge({
updatePluginCredentialCatalog: async (nextEntries) => nextEntries.length,
});
assert.equal(await pluginExtensionBridge.updateCredentialCatalog(entries), 2);
setBridge({
updatePluginCredentialCatalog: async () => {
throw new Error("secure storage unavailable");
},
});
await assert.rejects(
pluginExtensionBridge.updateCredentialCatalog(entries),
/secure storage unavailable/u,
);
assert.deepEqual(pluginExtensionBridge.getCredentialCatalogIds(), []);
assert.deepEqual(published, [
entries.map((entry) => entry.id),
[],
entries.map((entry) => entry.id),
[],
]);
unsubscribe();
});

View File

@@ -0,0 +1,82 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const requireBridge = () => {
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Netcatty desktop bridge is unavailable");
return bridge;
};
const EMPTY_CREDENTIAL_IDS: ReadonlyArray<string> = Object.freeze([]);
let credentialCatalogIds = EMPTY_CREDENTIAL_IDS;
const credentialCatalogListeners = new Set<() => void>();
const publishCredentialCatalogIds = (ids: ReadonlyArray<string>) => {
const next = ids.length > 0 ? Object.freeze([...new Set(ids)]) : EMPTY_CREDENTIAL_IDS;
if (next.length === credentialCatalogIds.length
&& next.every((id, index) => id === credentialCatalogIds[index])) return;
credentialCatalogIds = next;
for (const listener of credentialCatalogListeners) listener();
};
export const pluginExtensionBridge = Object.freeze({
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
},
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
try {
const accepted = await (requireBridge().updatePluginCredentialCatalog?.(entries) ?? 0);
publishCredentialCatalogIds(accepted === entries.length ? entries.map((entry) => entry.id) : []);
return accepted;
} catch (error) {
publishCredentialCatalogIds([]);
throw error;
}
},
getCredentialCatalogIds() {
return credentialCatalogIds;
},
subscribeCredentialCatalog(listener: () => void) {
credentialCatalogListeners.add(listener);
return () => credentialCatalogListeners.delete(listener);
},
async detectImporter(request: Parameters<NonNullable<NetcattyBridge["detectPluginImporter"]>>[0]) {
const bridge = requireBridge();
if (!bridge.detectPluginImporter) return null;
return bridge.detectPluginImporter(request);
},
async parseImporterFile(request: Parameters<NonNullable<NetcattyBridge["parsePluginImporterFile"]>>[0]) {
const bridge = requireBridge();
if (!bridge.parsePluginImporterFile) throw new Error("Plugin importer bridge is unavailable");
return bridge.parsePluginImporterFile(request);
},
async cancelRequest(requestId: string) {
return requireBridge().cancelPluginExtensionRequest?.(requestId) ?? false;
},
async selectImporterFile() {
const bridge = requireBridge();
if (!bridge.selectPluginImporterFile) throw new Error("Plugin importer file selection is unavailable");
return bridge.selectPluginImporterFile();
},
async releaseImporterFile(selectionToken: string) {
return requireBridge().releasePluginImporterFile?.(selectionToken) ?? false;
},
onImporterProgress(listener: Parameters<NonNullable<NetcattyBridge["onPluginImporterProgress"]>>[0]) {
return netcattyBridge.get()?.onPluginImporterProgress?.(listener) ?? (() => {});
},
onAuthenticationChallenge(listener: Parameters<NonNullable<NetcattyBridge["onPluginAuthenticationChallenge"]>>[0]) {
return netcattyBridge.get()?.onPluginAuthenticationChallenge?.(listener) ?? (() => {});
},
onContributionsChanged(listener: Parameters<NonNullable<NetcattyBridge["onPluginContributionsChanged"]>>[0]) {
return netcattyBridge.get()?.onPluginContributionsChanged?.(listener) ?? (() => {});
},
async respondAuthenticationChallenge(
response: Parameters<NonNullable<NetcattyBridge["respondPluginAuthenticationChallenge"]>>[0],
) {
const bridge = requireBridge();
if (!bridge.respondPluginAuthenticationChallenge) throw new Error("Plugin authentication bridge is unavailable");
return bridge.respondPluginAuthenticationChallenge(response);
},
async openExternal(url: string) {
return requireBridge().openExternal?.(url);
},
});

View File

@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION } from '../../infrastructure/config/storageKeys';
import {
commitPluginImporterTransaction,
recoverPluginImporterTransaction,
} from './pluginImporterTransaction';
const storage = (initial: Record<string, string> = {}) => {
const values = new Map(Object.entries(initial));
return {
values,
read<T>(key: string): T | null {
const value = values.get(key);
return value === undefined ? null : JSON.parse(value) as T;
},
readString: (key: string) => values.get(key) ?? null,
write<T>(key: string, value: T) { values.set(key, JSON.stringify(value)); return true; },
writeString(key: string, value: string) { values.set(key, value); return true; },
remove(key: string) { values.delete(key); },
};
};
test('plugin importer transaction commits all keys and removes its journal', () => {
const target = storage({ hosts: JSON.stringify(['old']) });
commitPluginImporterTransaction(target, [
['hosts', ['new']],
['keys', ['key']],
]);
assert.deepEqual(target.read('hosts'), ['new']);
assert.deepEqual(target.read('keys'), ['key']);
assert.equal(target.readString(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION), null);
});
test('plugin importer recovery rolls back a crash during the prepared phase', () => {
const target = storage({ hosts: JSON.stringify(['partial']), keys: JSON.stringify(['old-key']) });
target.write(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION, {
version: 1,
phase: 'prepared',
previous: [
{ key: 'hosts', value: JSON.stringify(['old-host']) },
{ key: 'keys', value: JSON.stringify(['old-key']) },
],
});
assert.equal(recoverPluginImporterTransaction(target, new Set(['hosts', 'keys'])), 'rolled-back');
assert.deepEqual(target.read('hosts'), ['old-host']);
assert.deepEqual(target.read('keys'), ['old-key']);
assert.equal(target.readString(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION), null);
});
test('Vault importer recovery accepts a safe subset of transaction keys', () => {
const target = storage({ groups: JSON.stringify(['partial']), sources: JSON.stringify(['partial-source']) });
target.write(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION, {
version: 1,
phase: 'prepared',
previous: [
{ key: 'groups', value: JSON.stringify(['old-group']) },
{ key: 'sources', value: JSON.stringify(['old-source']) },
],
});
assert.equal(
recoverPluginImporterTransaction(target, new Set(['hosts', 'groups', 'sources'])),
'rolled-back',
);
assert.deepEqual(target.read('groups'), ['old-group']);
assert.deepEqual(target.read('sources'), ['old-source']);
});
test('Vault importer transaction rejects writes that do not actually persist', () => {
const target = storage({ hosts: JSON.stringify(['old']) });
const lyingStorage = {
...target,
write<T>(key: string, value: T) {
if (key === 'hosts') return true;
return target.write(key, value);
},
};
assert.throws(
() => commitPluginImporterTransaction(lyingStorage, [['hosts', ['new']]]),
/rejected importer transaction/,
);
assert.deepEqual(target.read('hosts'), ['old']);
});
test('Vault importer transaction preserves an unfinished recovery record', () => {
const recoveryRecord = JSON.stringify({
version: 1,
phase: 'prepared',
previous: [{ key: 'hosts', value: JSON.stringify(['old']) }],
});
const target = storage({
hosts: JSON.stringify(['partial']),
[STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION]: recoveryRecord,
});
assert.throws(
() => commitPluginImporterTransaction(target, [['hosts', ['new']]]),
/unfinished Vault import recovery record/,
);
assert.equal(target.readString(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION), recoveryRecord);
assert.deepEqual(target.read('hosts'), ['partial']);
});
test('plugin importer recovery keeps fully committed values', () => {
const target = storage({ hosts: JSON.stringify(['new']) });
target.write(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION, { version: 1, phase: 'committed' });
assert.equal(recoverPluginImporterTransaction(target, new Set(['hosts'])), 'committed');
assert.deepEqual(target.read('hosts'), ['new']);
});

View File

@@ -0,0 +1,108 @@
import { STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION } from '../../infrastructure/config/storageKeys';
type TransactionStorage = {
read<T>(key: string): T | null;
readString(key: string): string | null;
write<T>(key: string, value: T): boolean;
writeString(key: string, value: string): boolean;
remove(key: string): void;
};
type PreviousEntry = { key: string; value: string | null };
type PreparedJournal = {
version: 1;
phase: 'prepared';
previous: PreviousEntry[];
};
type CommittedJournal = { version: 1; phase: 'committed' };
type ImportJournal = PreparedJournal | CommittedJournal;
const restorePrevious = (storage: TransactionStorage, previous: PreviousEntry[]) => {
for (const entry of previous) {
if (entry.value === null) {
storage.remove(entry.key);
if (storage.readString(entry.key) !== null) {
throw new Error(`Vault importer rollback failed for ${entry.key}`);
}
} else if (
!storage.writeString(entry.key, entry.value)
|| storage.readString(entry.key) !== entry.value
) {
throw new Error(`Vault importer rollback failed for ${entry.key}`);
}
}
};
const parseJournal = (value: unknown, allowedKeys: ReadonlySet<string>): ImportJournal | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const journal = value as Partial<ImportJournal>;
if (journal.version !== 1 || (journal.phase !== 'prepared' && journal.phase !== 'committed')) return null;
if (journal.phase === 'committed') return { version: 1, phase: 'committed' };
const previous = (journal as Partial<PreparedJournal>).previous;
if (!Array.isArray(previous) || previous.length === 0 || previous.length > allowedKeys.size) return null;
const seen = new Set<string>();
for (const entry of previous) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)
|| typeof entry.key !== 'string' || !allowedKeys.has(entry.key) || seen.has(entry.key)
|| (entry.value !== null && typeof entry.value !== 'string')) return null;
seen.add(entry.key);
}
return { version: 1, phase: 'prepared', previous: previous as PreviousEntry[] };
};
export function recoverPluginImporterTransaction(
storage: TransactionStorage,
allowedKeys: ReadonlySet<string>,
): 'none' | 'rolled-back' | 'committed' | 'discarded' {
if (storage.readString(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION) === null) return 'none';
const raw = storage.read<unknown>(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
if (raw === null) {
storage.remove(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
return 'discarded';
}
const journal = parseJournal(raw, allowedKeys);
if (!journal) {
storage.remove(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
return 'discarded';
}
if (journal.phase === 'prepared') restorePrevious(storage, journal.previous);
storage.remove(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
return journal.phase === 'prepared' ? 'rolled-back' : 'committed';
}
export function commitPluginImporterTransaction(
storage: TransactionStorage,
writes: ReadonlyArray<readonly [key: string, value: unknown]>,
): void {
if (storage.readString(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION) !== null) {
throw new Error('An unfinished Vault import recovery record already exists');
}
const keys = new Set(writes.map(([key]) => key));
if (keys.size !== writes.length || keys.has(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION)) {
throw new Error('Vault importer transaction keys are invalid');
}
const previous = writes.map(([key]) => ({ key, value: storage.readString(key) }));
const prepared: PreparedJournal = { version: 1, phase: 'prepared', previous };
if (!storage.write(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION, prepared)) {
throw new Error('Vault storage rejected the importer transaction journal');
}
try {
for (const [key, value] of writes) {
const expected = JSON.stringify(value);
if (expected === undefined
|| !storage.write(key, value)
|| storage.readString(key) !== expected) {
throw new Error(`Vault storage rejected importer transaction key ${key}`);
}
}
const committed: CommittedJournal = { version: 1, phase: 'committed' };
if (!storage.write(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION, committed)) {
throw new Error('Vault storage rejected the importer transaction commit marker');
}
} catch (error) {
restorePrevious(storage, previous);
storage.remove(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
throw error;
}
storage.remove(STORAGE_KEY_PLUGIN_IMPORT_TRANSACTION);
}

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isPluginShortcutEditableEvent,
normalizePluginKeyboardEvent,
normalizePluginShortcut,
resolvePluginShortcutPlatform,
} from './pluginKeybindings';
function editableTarget(matcher: RegExp) {
return {
closest(selector: string) { return matcher.test(selector) ? this : null; },
} as unknown as EventTarget;
}
test('plugin keybindings canonicalize aliases, named keys, and modifier order', () => {
assert.equal(normalizePluginShortcut('Control + Space', 'linux'), 'ctrl+space');
assert.equal(normalizePluginShortcut('Esc', 'linux'), 'escape');
assert.equal(normalizePluginShortcut('Ctrl+Up', 'linux'), 'ctrl+arrowup');
assert.equal(normalizePluginShortcut('Shift+Ctrl+P', 'linux'), 'ctrl+shift+p');
assert.equal(normalizePluginShortcut('Mod+P', 'mac'), 'meta+p');
assert.equal(normalizePluginShortcut('Mod+P', 'windows'), 'ctrl+p');
});
test('browser keyboard events use the same canonical shortcut representation', () => {
assert.equal(normalizePluginKeyboardEvent({
key: ' ', metaKey: false, ctrlKey: true, altKey: false, shiftKey: false,
}), 'ctrl+space');
assert.equal(normalizePluginKeyboardEvent({
key: 'Esc', metaKey: false, ctrlKey: false, altKey: false, shiftKey: false,
}), 'escape');
assert.equal(normalizePluginKeyboardEvent({
key: 'ArrowUp', metaKey: false, ctrlKey: true, altKey: false, shiftKey: false,
}), 'ctrl+arrowup');
assert.equal(normalizePluginKeyboardEvent({
key: '!', code: 'Digit1', metaKey: false, ctrlKey: false, altKey: false, shiftKey: true,
}), 'shift+1');
});
test('plugin keybindings reject ambiguous declarations and resolve host platforms', () => {
assert.equal(normalizePluginShortcut('Ctrl+Ctrl+P', 'linux'), null);
assert.equal(normalizePluginShortcut('Ctrl+Meta+P', 'mac'), null);
assert.equal(normalizePluginShortcut('P+Shift', 'linux'), null);
assert.equal(resolvePluginShortcutPlatform('MacIntel'), 'mac');
assert.equal(resolvePluginShortcutPlatform('Win32'), 'windows');
assert.equal(resolvePluginShortcutPlatform('Linux x86_64'), 'linux');
});
test('plugin shortcuts are suppressed throughout editable and Monaco surfaces', () => {
for (const matcher of [/\[contenteditable\]/u, /\[role="textbox"\]/u, /\.monaco-editor/u, /textarea/u]) {
assert.equal(isPluginShortcutEditableEvent({ target: editableTarget(matcher) }), true);
}
assert.equal(isPluginShortcutEditableEvent({
target: null,
composedPath: () => [editableTarget(/\.monaco-inputbox/u)],
}), true);
assert.equal(isPluginShortcutEditableEvent({
target: { closest: () => null } as unknown as EventTarget,
}), false);
});

View File

@@ -0,0 +1,136 @@
export type PluginShortcutPlatform = 'mac' | 'windows' | 'linux';
const MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const;
const PRIMARY_MODIFIERS = new Set(['meta', 'ctrl']);
const NAMED_KEYS = new Map<string, string>([
[' ', 'space'],
['spacebar', 'space'],
['space', 'space'],
['arrowdown', 'arrowdown'],
['down', 'arrowdown'],
['arrowleft', 'arrowleft'],
['left', 'arrowleft'],
['arrowright', 'arrowright'],
['right', 'arrowright'],
['arrowup', 'arrowup'],
['up', 'arrowup'],
['backspace', 'backspace'],
['delete', 'delete'],
['del', 'delete'],
['end', 'end'],
['enter', 'enter'],
['return', 'enter'],
['escape', 'escape'],
['esc', 'escape'],
['home', 'home'],
['insert', 'insert'],
['minus', 'minus'],
['-', 'minus'],
['pagedown', 'pagedown'],
['pageup', 'pageup'],
['plus', 'plus'],
['+', 'plus'],
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'input',
'textarea',
'select',
'[contenteditable]',
'[role="textbox"]',
'.monaco-editor',
'.monaco-diff-editor',
'.monaco-inputbox',
'.monaco-menu-container',
].join(', ');
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
&& Boolean(closest.call(node, PLUGIN_SHORTCUT_EDITABLE_SELECTOR));
}
export function isPluginShortcutEditableEvent(event: {
target: EventTarget | null;
composedPath?: () => EventTarget[];
}): boolean {
if (hasEditableShortcutAncestor(event.target)) return true;
return event.composedPath?.().some(hasEditableShortcutAncestor) ?? false;
}
function normalizeModifier(token: string, platform: PluginShortcutPlatform): string | null {
switch (token) {
case 'commandorcontrol':
case 'cmdorctrl':
case 'mod':
return platform === 'mac' ? 'meta' : 'ctrl';
case 'command':
case 'cmd':
case 'meta':
return 'meta';
case 'control':
case 'ctrl':
return 'ctrl';
case 'option':
case 'alt':
return 'alt';
case 'shift':
return 'shift';
default:
return null;
}
}
function normalizeKey(token: string): string | null {
const lower = token.toLowerCase();
if (/^[a-z0-9]$/u.test(lower) || /^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(lower)) return lower;
return NAMED_KEYS.get(lower) ?? null;
}
export function resolvePluginShortcutPlatform(platform: string): PluginShortcutPlatform {
if (/Mac|iPhone|iPad/u.test(platform)) return 'mac';
if (/Win/u.test(platform)) return 'windows';
return 'linux';
}
export function normalizePluginShortcut(
shortcut: string,
platform: PluginShortcutPlatform,
): string | null {
if (typeof shortcut !== 'string' || shortcut.length < 1 || shortcut.length > 128) return null;
const tokens = shortcut.split('+').map((token) => token.trim());
if (tokens.some((token) => token.length === 0) || tokens.length > 5) return null;
const modifiers = new Set<string>();
let key: string | null = null;
for (const token of tokens) {
const modifier = normalizeModifier(token.toLowerCase(), platform);
if (modifier) {
if (key || modifiers.has(modifier)
|| (PRIMARY_MODIFIERS.has(modifier)
&& [...modifiers].some((item) => PRIMARY_MODIFIERS.has(item)))) return null;
modifiers.add(modifier);
continue;
}
if (key) return null;
key = normalizeKey(token);
if (!key) return null;
}
if (!key) return null;
return [...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)), key].join('+');
}
export function normalizePluginKeyboardEvent(event: Pick<KeyboardEvent,
'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'> & { code?: string }): string | null {
const codeKey = event.code?.match(/^Key([A-Z])$/u)?.[1]?.toLowerCase()
?? event.code?.match(/^Digit([0-9])$/u)?.[1]
?? null;
const key = normalizeKey(event.key) ?? codeKey;
if (!key) return null;
return [
event.metaKey ? 'meta' : '',
event.ctrlKey ? 'ctrl' : '',
event.altKey ? 'alt' : '',
event.shiftKey ? 'shift' : '',
key,
].filter(Boolean).join('+');
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
_resetSharedPluginRuntimeStatusForTests,
getSharedPluginRuntimeStatus,
invalidateSharedPluginRuntimeStatus,
} from "./pluginRuntimeStatusCache";
test("plugin runtime status is fetched once for concurrent terminal consumers", async () => {
_resetSharedPluginRuntimeStatusForTests();
let calls = 0;
const bridge = {
async getPluginRuntimeStatus() {
calls += 1;
await Promise.resolve();
return { available: true } as NetcattyPluginRuntimeStatus;
},
};
const statuses = await Promise.all(
Array.from({ length: 20 }, () => getSharedPluginRuntimeStatus(bridge)),
);
assert.equal(calls, 1);
assert.equal(statuses.every((status) => status.available), true);
invalidateSharedPluginRuntimeStatus(bridge);
await getSharedPluginRuntimeStatus(bridge);
assert.equal(calls, 2);
});

View File

@@ -0,0 +1,39 @@
type PluginRuntimeBridge = Pick<NetcattyBridge, "getPluginRuntimeStatus">;
interface StatusCacheEntry {
value?: NetcattyPluginRuntimeStatus;
pending?: Promise<NetcattyPluginRuntimeStatus>;
}
let statusByBridge = new WeakMap<object, StatusCacheEntry>();
export async function getSharedPluginRuntimeStatus(
bridge: PluginRuntimeBridge,
): Promise<NetcattyPluginRuntimeStatus> {
const key = bridge as object;
let entry = statusByBridge.get(key);
if (!entry) {
entry = {};
statusByBridge.set(key, entry);
}
if (entry.value) return entry.value;
if (entry.pending) return entry.pending;
const pending = bridge.getPluginRuntimeStatus!().then((status) => {
entry!.value = status;
return status;
}).finally(() => {
if (entry?.pending === pending) entry.pending = undefined;
});
entry.pending = pending;
return pending;
}
export function invalidateSharedPluginRuntimeStatus(bridge: object): void {
const entry = statusByBridge.get(bridge);
if (entry) entry.value = undefined;
}
export function _resetSharedPluginRuntimeStatusForTests(): void {
statusByBridge = new WeakMap();
}

View File

@@ -0,0 +1,432 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
collectPluginTerminalProviderKinds,
isPluginTerminalProviderKindAvailable,
PluginTerminalProviderAvailability,
PluginTerminalProviderRegistry,
} from './pluginTerminalProviderRegistry.ts';
const session: NetcattyTerminalSessionSnapshot = {
sessionId: 'session-1',
protocol: 'ssh',
status: 'connected',
};
test('terminal Provider availability fails closed before or after failed enumeration', async () => {
assert.equal(isPluginTerminalProviderKindAvailable(null, 'terminal.matcher'), false);
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { throw new Error('bridge unavailable'); },
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() { return []; },
});
const kinds = await collectPluginTerminalProviderKinds(registry, [
'terminal.matcher',
'terminal.background',
]);
assert.equal(isPluginTerminalProviderKindAvailable(kinds, 'terminal.matcher'), false);
assert.equal(isPluginTerminalProviderKindAvailable(kinds, 'terminal.background'), false);
registry.dispose();
});
test('terminal Provider availability ignores an older enumeration that finishes last', async () => {
let resolveOlder: ((providers: NetcattyTerminalProviderContribution[]) => void) | undefined;
const olderRegistry = new PluginTerminalProviderRegistry({
listPluginTerminalProviders() {
return new Promise((resolve) => { resolveOlder = resolve; });
},
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() { return []; },
});
const newerRegistry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() {
return [{
pluginId: 'com.example.matcher',
pluginVersion: '1.0.0',
pluginDisplayName: 'Matcher',
provider: {
id: 'com.example.matcher.output',
label: 'Output matcher',
kind: 'terminal.matcher',
},
}];
},
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() { return []; },
});
const availability = new PluginTerminalProviderAvailability();
const older = availability.refresh(olderRegistry, ['terminal.matcher']);
const newer = availability.refresh(newerRegistry, ['terminal.matcher']);
assert.equal(await newer, true);
assert.equal(availability.has('terminal.matcher'), true);
resolveOlder?.([]);
assert.equal(await older, false);
assert.equal(availability.has('terminal.matcher'), true);
olderRegistry.dispose();
newerRegistry.dispose();
});
test('terminal Provider registry coalesces immutable enumeration until contributions change', async () => {
let changed: (() => void) | undefined;
let listCalls = 0;
const resolvers: Array<(providers: NetcattyTerminalProviderContribution[]) => void> = [];
const registry = new PluginTerminalProviderRegistry({
listPluginTerminalProviders() {
listCalls += 1;
return new Promise((resolve) => { resolvers.push(resolve); });
},
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() { return []; },
onPluginContributionsChanged(listener) {
changed = listener;
return () => {};
},
});
const first = registry.listProviders({ kind: 'terminal.matcher' });
const duplicate = registry.listProviders({ kind: 'terminal.matcher' });
assert.equal(listCalls, 1);
resolvers[0]?.([]);
assert.strictEqual(await first, await duplicate);
assert.strictEqual(await registry.listProviders({ kind: 'terminal.matcher' }), await first);
assert.equal(listCalls, 1);
changed?.();
const refreshed = registry.listProviders({ kind: 'terminal.matcher' });
assert.equal(listCalls, 2);
resolvers[1]?.([]);
await refreshed;
registry.dispose();
});
test('terminal Provider registry suppresses repeated lifecycle RPC after the bridge is unavailable', async () => {
let changed: (() => void) | undefined;
let listCalls = 0;
let lifecycleCalls = 0;
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() {
listCalls += 1;
throw new Error('PLUGIN_DEVELOPMENT_DISABLED');
},
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() {
lifecycleCalls += 1;
throw new Error('PLUGIN_DEVELOPMENT_DISABLED');
},
onPluginContributionsChanged(listener) {
changed = listener;
return () => {};
},
});
await assert.rejects(registry.listProviders({ kind: 'terminal.link' }), /PLUGIN_DEVELOPMENT_DISABLED/);
assert.deepEqual(await registry.listProviders({ kind: 'terminal.link' }), []);
assert.equal(listCalls, 1);
await registry.publishSessionEvent({ type: 'created', session });
await registry.publishSessionEvent({ type: 'titleChanged', session: { ...session, title: 'ready' } });
assert.equal(lifecycleCalls, 0);
changed?.();
await assert.rejects(
registry.publishSessionEvent({ type: 'cwdChanged', session: { ...session, cwd: '/work' } }),
/PLUGIN_DEVELOPMENT_DISABLED/,
);
await registry.publishSessionEvent({ type: 'resized', session: { ...session, cols: 80, rows: 24 } });
assert.equal(lifecycleCalls, 1);
registry.dispose();
});
test('terminal Provider registry cancels superseded requests and suppresses stale results', async () => {
const cancellations: string[] = [];
const resolvers: Array<(value: ReadonlyArray<NetcattyTerminalProviderResult>) => void> = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
providePluginTerminal() {
return new Promise((resolve) => resolvers.push(resolve));
},
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
async publishPluginTerminalSessionEvent() { return []; },
});
const first = registry.request({
kind: 'terminal.completion',
operation: 'provideCompletions',
session,
payload: { input: 'g' },
});
const second = registry.request({
kind: 'terminal.completion',
operation: 'provideCompletions',
session,
payload: { input: 'gi' },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cancellations.length, 1);
resolvers[1]([]);
const secondResult = await second;
resolvers[0]([]);
const firstResult = await first;
assert.equal(secondResult.stale, false);
assert.equal(firstResult.stale, true);
assert.deepEqual(firstResult.results, []);
});
test('terminal Provider registry aborts an in-flight bridge request and settles stale', async () => {
const cancellations: string[] = [];
const requests: NetcattyTerminalProviderRequest[] = [];
const controller = new AbortController();
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
providePluginTerminal(request) {
requests.push(request);
return new Promise(() => {});
},
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
async publishPluginTerminalSessionEvent() { return []; },
});
const pending = registry.request({
kind: 'terminal.completion',
operation: 'provideCompletions',
session,
}, { signal: controller.signal });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(requests.length, 1);
controller.abort();
const result = await pending;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(result.stale, true);
assert.deepEqual(result.results, []);
assert.deepEqual(cancellations, [requests[0].requestId]);
registry.dispose();
});
test('terminal Provider registry scopes link supersession per buffer line', async () => {
const cancellations: string[] = [];
const resolvers: Array<(value: ReadonlyArray<NetcattyTerminalProviderResult>) => void> = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
providePluginTerminal() { return new Promise((resolve) => resolvers.push(resolve)); },
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
async publishPluginTerminalSessionEvent() { return []; },
});
const firstLine = registry.request({
kind: 'terminal.link',
operation: 'provideLinks',
session,
supersessionKey: 'line:1',
});
const secondLine = registry.request({
kind: 'terminal.link',
operation: 'provideLinks',
session,
supersessionKey: 'line:2',
});
assert.equal(cancellations.length, 0);
const replacementFirstLine = registry.request({
kind: 'terminal.link',
operation: 'provideLinks',
session,
supersessionKey: 'line:1',
});
assert.equal(cancellations.length, 1);
resolvers[1]([]);
resolvers[2]([]);
assert.equal((await secondLine).stale, false);
assert.equal((await replacementFirstLine).stale, false);
resolvers[0]([]);
assert.equal((await firstLine).stale, true);
});
test('terminal Provider registry returns a stale response when a superseded bridge request rejects', async () => {
const resolvers: Array<{
resolve: (value: ReadonlyArray<NetcattyTerminalProviderResult>) => void;
reject: (error: Error) => void;
}> = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
providePluginTerminal() {
return new Promise((resolve, reject) => resolvers.push({ resolve, reject }));
},
async cancelPluginTerminalRequest() { return true; },
async publishPluginTerminalSessionEvent() { return []; },
});
const first = registry.request({
kind: 'terminal.decoration',
operation: 'provideDecorations',
session,
});
const second = registry.request({
kind: 'terminal.decoration',
operation: 'provideDecorations',
session,
});
resolvers[1].resolve([]);
assert.equal((await second).stale, false);
resolvers[0].reject(new Error('cancelled'));
const firstResult = await first;
assert.equal(firstResult.stale, true);
assert.deepEqual(firstResult.results, []);
});
test('terminal Provider registry freezes enumeration and cancels all session requests', async () => {
const cancellations: string[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() {
return [{
pluginId: 'com.example',
pluginVersion: '1.0.0',
pluginDisplayName: 'Example',
provider: { id: 'com.example.completion', label: 'Completion', kind: 'terminal.completion' },
}];
},
providePluginTerminal() { return new Promise(() => {}); },
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
async publishPluginTerminalSessionEvent() { return []; },
});
const providers = await registry.listProviders({ kind: 'terminal.completion' });
assert.equal(Object.isFrozen(providers), true);
assert.equal(Object.isFrozen(providers[0].provider), true);
void registry.request({ kind: 'terminal.completion', operation: 'provide', session });
void registry.request({ kind: 'terminal.decoration', operation: 'provide', session });
registry.cancelSession(session.sessionId);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cancellations.length, 2);
});
test('terminal Provider registry publishes metadata-only lifecycle snapshots', async () => {
const events: NetcattyTerminalSessionEvent[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
});
await registry.publishSessionEvent({ type: 'connected', session });
await registry.publishSessionEvent({
type: 'cwdChanged',
session: { sessionId: session.sessionId, protocol: 'ssh', status: 'connected', cwd: '/srv/app' },
});
assert.deepEqual(events, [
{ type: 'connected', session },
{ type: 'cwdChanged', session: { ...session, cwd: '/srv/app' } },
]);
assert.equal(Object.isFrozen(events[0]), true);
assert.equal(Object.isFrozen(events[0].session), true);
});
test('terminal Provider lifecycle clears optional fields instead of retaining stale metadata', async () => {
const events: NetcattyTerminalSessionEvent[] = [];
const requests: NetcattyTerminalProviderRequest[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
async providePluginTerminal(request) { requests.push(request); return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
});
await registry.publishSessionEvent({
type: 'created',
session: { ...session, cwd: '/srv/app', title: 'Application' },
});
await registry.publishSessionEvent({ type: 'cwdChanged', session });
await registry.publishSessionEvent({ type: 'titleChanged', session });
await registry.request({
kind: 'terminal.completion',
operation: 'provide',
session,
});
assert.equal(Object.hasOwn(events[1].session, 'cwd'), false);
assert.equal(Object.hasOwn(events[1].session, 'title'), true);
assert.equal(Object.hasOwn(events[2].session, 'cwd'), false);
assert.equal(Object.hasOwn(events[2].session, 'title'), false);
assert.equal(Object.hasOwn(requests[0].session, 'cwd'), false);
assert.equal(Object.hasOwn(requests[0].session, 'title'), false);
});
test('terminal Provider reconnect lifecycle clears omitted connection-scoped metadata', async () => {
const events: NetcattyTerminalSessionEvent[] = [];
const requests: NetcattyTerminalProviderRequest[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
async providePluginTerminal(request) { requests.push(request); return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
});
await registry.publishSessionEvent({
type: 'connected',
session: { ...session, cwd: '/srv/app', title: 'Application', alternateScreen: true },
});
await registry.publishSessionEvent({ type: 'disconnected', session: { ...session, status: 'disconnected' } });
await registry.publishSessionEvent({ type: 'reconnected', session });
await registry.request({ kind: 'terminal.completion', operation: 'provide', session });
for (const event of events.slice(1)) {
assert.equal(Object.hasOwn(event.session, 'cwd'), false);
assert.equal(Object.hasOwn(event.session, 'title'), false);
assert.equal(Object.hasOwn(event.session, 'alternateScreen'), false);
}
assert.equal(Object.hasOwn(requests[0].session, 'cwd'), false);
assert.equal(Object.hasOwn(requests[0].session, 'title'), false);
assert.equal(Object.hasOwn(requests[0].session, 'alternateScreen'), false);
});
test('terminal Provider requests merge the latest lifecycle snapshot before invocation', async () => {
const requests: NetcattyTerminalProviderRequest[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
async providePluginTerminal(request) { requests.push(request); return []; },
async cancelPluginTerminalRequest() { return false; },
async publishPluginTerminalSessionEvent() { return []; },
});
await registry.publishSessionEvent({
type: 'created',
session: { ...session, workspaceId: 'workspace-1', title: 'Initial title', cols: 100, rows: 30 },
});
await registry.request({
kind: 'terminal.completion',
operation: 'provide',
session: { ...session, cwd: '/srv/app' },
});
assert.deepEqual(requests[0].session, {
...session,
workspaceId: 'workspace-1',
title: 'Initial title',
cols: 100,
rows: 30,
cwd: '/srv/app',
});
assert.equal(Object.isFrozen(requests[0].session), true);
});
test('terminal Provider registry forwards contribution lifecycle invalidation', () => {
let bridgeListener: (() => void) | undefined;
let disposed = false;
const cancellations: string[] = [];
const registry = new PluginTerminalProviderRegistry({
async listPluginTerminalProviders() { return []; },
async providePluginTerminal() { return []; },
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
async publishPluginTerminalSessionEvent() { return []; },
onPluginContributionsChanged(listener) {
bridgeListener = listener;
return () => { disposed = true; };
},
});
let changes = 0;
const unsubscribe = registry.onDidChangeProviders(() => { changes += 1; });
void registry.request({ kind: 'terminal.completion', operation: 'provide', session });
bridgeListener?.();
assert.equal(changes, 1);
assert.equal(cancellations.length, 1);
unsubscribe();
bridgeListener?.();
assert.equal(changes, 1);
registry.dispose();
assert.equal(disposed, true);
});

View File

@@ -0,0 +1,319 @@
export interface PluginTerminalProviderBridge {
listPluginTerminalProviders(options: NetcattyTerminalProviderQuery): Promise<ReadonlyArray<NetcattyTerminalProviderContribution>>;
providePluginTerminal(request: NetcattyTerminalProviderRequest): Promise<ReadonlyArray<NetcattyTerminalProviderResult>>;
cancelPluginTerminalRequest(requestId: string): Promise<boolean>;
publishPluginTerminalSessionEvent(event: NetcattyTerminalSessionEvent): Promise<ReadonlyArray<{ pluginId: string; delivered: boolean }>>;
onPluginContributionsChanged?(callback: () => void): () => void;
}
export interface PluginTerminalProviderResponse {
readonly requestId: string;
readonly stale: boolean;
readonly results: ReadonlyArray<NetcattyTerminalProviderResult>;
}
export interface PluginTerminalProviderRequestOptions {
readonly signal?: AbortSignal;
}
function freezeValue<T>(value: T): Readonly<T> {
const clone = structuredClone(value);
const freeze = (item: unknown): void => {
if (!item || typeof item !== 'object' || Object.isFrozen(item)) return;
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
Object.freeze(item);
};
freeze(clone);
return clone as Readonly<T>;
}
function requestKey(sessionId: string, kind: NetcattyTerminalProviderKind, supersessionKey: string): string {
return `${sessionId}\0${kind}\0${supersessionKey}`;
}
function createRequestId(): string {
return `terminal-${crypto.randomUUID()}`;
}
function mergeLifecycleSessionSnapshot(
previous: NetcattyTerminalSessionSnapshot | undefined,
event: NetcattyTerminalSessionEvent,
): Readonly<NetcattyTerminalSessionSnapshot> {
const session: NetcattyTerminalSessionSnapshot = { ...(previous ?? {}), ...event.session };
if (event.type === 'disconnected' || event.type === 'reconnected') {
if (!Object.hasOwn(event.session, 'cwd')) delete session.cwd;
if (!Object.hasOwn(event.session, 'title')) delete session.title;
if (!Object.hasOwn(event.session, 'alternateScreen')) delete session.alternateScreen;
}
if (event.type === 'cwdChanged' && !Object.hasOwn(event.session, 'cwd')) delete session.cwd;
if (event.type === 'titleChanged' && !Object.hasOwn(event.session, 'title')) delete session.title;
return freezeValue(session);
}
export class PluginTerminalProviderRegistry {
readonly #bridge: PluginTerminalProviderBridge;
readonly #activeRequests = new Map<string, string>();
readonly #sessionSnapshots = new Map<string, NetcattyTerminalSessionSnapshot>();
readonly #providerListeners = new Set<() => void>();
readonly #providerListCache = new Map<string, ReadonlyArray<NetcattyTerminalProviderContribution>>();
readonly #pendingProviderLists = new Map<string, Promise<ReadonlyArray<NetcattyTerminalProviderContribution>>>();
readonly #disposeContributionListener: (() => void) | undefined;
#providerListGeneration = 0;
#bridgeAvailability: 'unknown' | 'available' | 'unavailable' = 'unknown';
#disposed = false;
constructor(bridge: PluginTerminalProviderBridge) {
this.#bridge = bridge;
this.#disposeContributionListener = bridge.onPluginContributionsChanged?.(() => {
this.#providerListGeneration += 1;
this.#bridgeAvailability = 'unknown';
this.#providerListCache.clear();
this.#pendingProviderLists.clear();
for (const requestId of this.#activeRequests.values()) {
void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false);
}
this.#activeRequests.clear();
for (const listener of [...this.#providerListeners]) {
try { listener(); } catch { /* isolate application listeners */ }
}
});
}
onDidChangeProviders(listener: () => void): () => void {
if (this.#disposed) return () => {};
this.#providerListeners.add(listener);
return () => this.#providerListeners.delete(listener);
}
async listProviders(query: NetcattyTerminalProviderQuery): Promise<ReadonlyArray<NetcattyTerminalProviderContribution>> {
if (this.#disposed) return Object.freeze([]);
if (this.#bridgeAvailability === 'unavailable') return Object.freeze([]);
const key = JSON.stringify(query);
const cached = this.#providerListCache.get(key);
if (cached) return cached;
const pending = this.#pendingProviderLists.get(key);
if (pending) return pending;
const generation = this.#providerListGeneration;
const request = (async () => {
try {
const providers = freezeValue(await this.#bridge.listPluginTerminalProviders(query));
if (generation === this.#providerListGeneration) {
if (this.#bridgeAvailability !== 'unavailable') this.#bridgeAvailability = 'available';
this.#providerListCache.set(key, providers);
}
return providers;
} catch (error) {
if (generation === this.#providerListGeneration) {
this.#bridgeAvailability = 'unavailable';
this.#providerListCache.clear();
}
throw error;
} finally {
if (this.#pendingProviderLists.get(key) === request) this.#pendingProviderLists.delete(key);
}
})();
this.#pendingProviderLists.set(key, request);
return request;
}
async request(
request: Omit<NetcattyTerminalProviderRequest, 'requestId'> & { supersessionKey?: string },
options: PluginTerminalProviderRequestOptions = {},
): Promise<PluginTerminalProviderResponse> {
if (this.#disposed || options.signal?.aborted) {
return Object.freeze({ requestId: '', stale: true, results: Object.freeze([]) });
}
const { supersessionKey: rawSupersessionKey, ...providerRequest } = request;
const supersessionKey = typeof rawSupersessionKey === 'string'
&& rawSupersessionKey.length > 0
&& rawSupersessionKey.length <= 128
? rawSupersessionKey
: 'default';
const previousSession = this.#sessionSnapshots.get(request.session.sessionId);
const session = freezeValue({ ...(previousSession ?? {}), ...request.session });
this.#sessionSnapshots.set(session.sessionId, session);
const key = requestKey(session.sessionId, request.kind, supersessionKey);
const previousRequestId = this.#activeRequests.get(key);
if (previousRequestId) void this.#bridge.cancelPluginTerminalRequest(previousRequestId).catch(() => false);
const requestId = createRequestId();
this.#activeRequests.set(key, requestId);
let bridgeRequestStarted = false;
let aborted = false;
let resolveAborted: (() => void) | undefined;
const abortedRequest = new Promise<{
readonly status: 'aborted';
}>((resolve) => {
resolveAborted = () => resolve({ status: 'aborted' });
});
const onAbort = () => {
if (aborted) return;
aborted = true;
if (this.#activeRequests.get(key) === requestId) this.#activeRequests.delete(key);
if (bridgeRequestStarted) {
void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false);
}
resolveAborted?.();
};
options.signal?.addEventListener('abort', onAbort, { once: true });
try {
if (options.signal?.aborted) onAbort();
if (aborted) {
return Object.freeze({ requestId, stale: true, results: Object.freeze([]) });
}
bridgeRequestStarted = true;
let bridgeResponse: Promise<ReadonlyArray<NetcattyTerminalProviderResult>>;
try {
bridgeResponse = this.#bridge.providePluginTerminal({
...providerRequest,
session,
requestId,
});
} catch (error) {
bridgeResponse = Promise.reject(error);
}
const bridgeRequest = bridgeResponse
.then(
(results) => ({ status: 'fulfilled' as const, results }),
(error: unknown) => ({ status: 'rejected' as const, error }),
);
const outcome = options.signal
? await Promise.race([bridgeRequest, abortedRequest])
: await bridgeRequest;
if (outcome.status === 'aborted') {
return Object.freeze({ requestId, stale: true, results: Object.freeze([]) });
}
if (outcome.status === 'rejected') {
const stale = this.#disposed || this.#activeRequests.get(key) !== requestId;
if (stale) return Object.freeze({ requestId, stale: true, results: Object.freeze([]) });
throw outcome.error;
}
const results = freezeValue(outcome.results);
const stale = this.#disposed || this.#activeRequests.get(key) !== requestId;
return Object.freeze({
requestId,
stale,
results: stale ? Object.freeze([]) : results,
});
} catch (error) {
const stale = this.#disposed || this.#activeRequests.get(key) !== requestId;
if (stale) return Object.freeze({ requestId, stale: true, results: Object.freeze([]) });
throw error;
} finally {
options.signal?.removeEventListener('abort', onAbort);
if (this.#activeRequests.get(key) === requestId) this.#activeRequests.delete(key);
}
}
async publishSessionEvent(event: NetcattyTerminalSessionEvent): Promise<void> {
if (this.#disposed) return;
const previous = this.#sessionSnapshots.get(event.session.sessionId);
const session = mergeLifecycleSessionSnapshot(previous, event);
if (event.type === 'disposed') this.#sessionSnapshots.delete(session.sessionId);
else this.#sessionSnapshots.set(session.sessionId, session);
if (this.#bridgeAvailability === 'unavailable') return;
try {
await this.#bridge.publishPluginTerminalSessionEvent(freezeValue({ ...event, session }));
this.#bridgeAvailability = 'available';
} catch (error) {
this.#bridgeAvailability = 'unavailable';
throw error;
}
}
cancelSession(sessionId: string): void {
this.#sessionSnapshots.delete(sessionId);
for (const [key, requestId] of [...this.#activeRequests]) {
if (!key.startsWith(`${sessionId}\0`)) continue;
this.#activeRequests.delete(key);
void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false);
}
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#providerListGeneration += 1;
for (const requestId of this.#activeRequests.values()) {
void this.#bridge.cancelPluginTerminalRequest(requestId).catch(() => false);
}
this.#activeRequests.clear();
this.#sessionSnapshots.clear();
this.#providerListeners.clear();
this.#providerListCache.clear();
this.#pendingProviderLists.clear();
this.#disposeContributionListener?.();
}
}
export async function collectPluginTerminalProviderKinds(
registry: PluginTerminalProviderRegistry | null,
kinds: readonly NetcattyTerminalProviderKind[],
): Promise<ReadonlySet<NetcattyTerminalProviderKind>> {
if (!registry) return new Set();
try {
const enumerations = await Promise.all(kinds.map(async (kind) => ({
kind,
providers: await registry.listProviders({ kind }),
})));
return new Set(enumerations
.filter((entry) => entry.providers.length > 0)
.map((entry) => entry.kind));
} catch {
return new Set();
}
}
export function isPluginTerminalProviderKindAvailable(
kinds: ReadonlySet<NetcattyTerminalProviderKind> | null,
kind: NetcattyTerminalProviderKind,
): boolean {
return kinds?.has(kind) ?? false;
}
export class PluginTerminalProviderAvailability {
#generation = 0;
#kinds: ReadonlySet<NetcattyTerminalProviderKind> | null = null;
async refresh(
registry: PluginTerminalProviderRegistry | null,
kinds: readonly NetcattyTerminalProviderKind[],
): Promise<boolean> {
const generation = ++this.#generation;
const next = await collectPluginTerminalProviderKinds(registry, kinds);
if (generation !== this.#generation) return false;
this.#kinds = next;
return true;
}
has(kind: NetcattyTerminalProviderKind): boolean {
return isPluginTerminalProviderKindAvailable(this.#kinds, kind);
}
}
export function createWindowPluginTerminalProviderRegistry(
bridge: NetcattyBridge | undefined = typeof window === 'undefined' ? undefined : netcattyBridge.get(),
): PluginTerminalProviderRegistry | null {
if (!bridge?.listPluginTerminalProviders
|| !bridge.providePluginTerminal
|| !bridge.cancelPluginTerminalRequest
|| !bridge.publishPluginTerminalSessionEvent) return null;
return new PluginTerminalProviderRegistry({
listPluginTerminalProviders: (options) => bridge.listPluginTerminalProviders!(options),
providePluginTerminal: (request) => bridge.providePluginTerminal!(request),
cancelPluginTerminalRequest: (requestId) => bridge.cancelPluginTerminalRequest!(requestId),
publishPluginTerminalSessionEvent: (event) => bridge.publishPluginTerminalSessionEvent!(event),
onPluginContributionsChanged: bridge.onPluginContributionsChanged
? (callback) => bridge.onPluginContributionsChanged!(callback)
: undefined,
});
}
let windowRegistry: PluginTerminalProviderRegistry | null | undefined;
export function getWindowPluginTerminalProviderRegistry(): PluginTerminalProviderRegistry | null {
if (windowRegistry !== undefined) return windowRegistry;
if (typeof window === 'undefined') return null;
windowRegistry = createWindowPluginTerminalProviderRegistry(netcattyBridge.get());
return windowRegistry;
}
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { publishPluginTerminalRuntimeLifecycleEvent } from './pluginTerminalRuntimeLifecycle.ts';
test('terminal runtime lifecycle events share the canonical session lifecycle sink', () => {
const calls: unknown[][] = [];
const lifecycle = {
onCommandSubmitted() { calls.push(['commandSubmitted']); },
onCommandCompleted() { calls.push(['commandCompleted']); },
onCwdChanged(cwd: string | null) { calls.push(['cwdChanged', cwd]); },
onTitleChanged(title: string | null) { calls.push(['titleChanged', title]); },
onResized(cols: number, rows: number) { calls.push(['resized', cols, rows]); },
onAlternateScreenChanged(alternateScreen: boolean) {
calls.push(['alternateScreenChanged', alternateScreen]);
},
};
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'cwdChanged', { cwd: '/srv/app' });
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'cwdChanged');
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'titleChanged', { title: 'Application' });
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'titleChanged');
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'resized', { cols: 120, rows: 40 });
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'alternateScreenChanged', { alternateScreen: true });
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'commandSubmitted');
publishPluginTerminalRuntimeLifecycleEvent(lifecycle, 'commandCompleted');
assert.deepEqual(calls, [
['cwdChanged', '/srv/app'],
['cwdChanged', null],
['titleChanged', 'Application'],
['titleChanged', null],
['resized', 120, 40],
['alternateScreenChanged', true],
['commandSubmitted'],
['commandCompleted'],
]);
});

View File

@@ -0,0 +1,42 @@
export interface PluginTerminalRuntimeLifecycleSink {
onCommandSubmitted(): void;
onCommandCompleted(): void;
onCwdChanged(cwd: string | null): void;
onTitleChanged(title: string | null): void;
onResized(cols: number, rows: number): void;
onAlternateScreenChanged(alternateScreen: boolean): void;
}
export type PluginTerminalRuntimeLifecycleEventType =
| 'commandSubmitted'
| 'commandCompleted'
| 'cwdChanged'
| 'titleChanged'
| 'resized'
| 'alternateScreenChanged';
export function publishPluginTerminalRuntimeLifecycleEvent(
lifecycle: PluginTerminalRuntimeLifecycleSink,
type: PluginTerminalRuntimeLifecycleEventType,
details: Partial<NetcattyTerminalSessionSnapshot> = {},
): void {
switch (type) {
case 'commandSubmitted':
lifecycle.onCommandSubmitted();
return;
case 'commandCompleted':
lifecycle.onCommandCompleted();
return;
case 'cwdChanged':
lifecycle.onCwdChanged(Object.hasOwn(details, 'cwd') ? details.cwd ?? null : null);
return;
case 'titleChanged':
lifecycle.onTitleChanged(Object.hasOwn(details, 'title') ? details.title ?? null : null);
return;
case 'resized':
if (details.cols != null && details.rows != null) lifecycle.onResized(details.cols, details.rows);
return;
case 'alternateScreenChanged':
if (details.alternateScreen != null) lifecycle.onAlternateScreenChanged(details.alternateScreen);
}
}

View File

@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = {
requestViewId: 'publisher.plugin.view',
contextKey: '{"netcatty.surface":"view"}',
value: { id: 'resolved-view' },
};
assert.equal(resolvePluginViewSnapshotSelection({
resolved: null,
previous,
loading: true,
requestedViewId: previous.requestViewId,
contextKey: previous.contextKey,
}), previous.value);
assert.equal(resolvePluginViewSnapshotSelection({
resolved: null,
previous,
loading: true,
requestedViewId: previous.requestViewId,
contextKey: '{"netcatty.surface":"terminal/toolbar"}',
}), null);
assert.equal(resolvePluginViewSnapshotSelection({
resolved: null,
previous,
loading: false,
requestedViewId: previous.requestViewId,
contextKey: previous.contextKey,
}), null);
});
test('native tab catalog reconciliation pauses for every in-flight query', () => {
assert.equal(shouldReconcilePluginViewTabCatalog({
loading: true,
}), false);
assert.equal(shouldReconcilePluginViewTabCatalog({
loading: false,
}), true);
});
test('active-tab context refresh cannot withdraw the plugin tab that triggered it', () => {
let activeTabId = 'vault';
const store = new PluginViewTabStore({
getActiveTabId: () => activeTabId,
setActiveTabId: (next) => { activeTabId = next; },
});
const tab = store.open({
pluginId: 'publisher.plugin',
pluginName: 'Plugin',
viewId: 'publisher.plugin.view',
title: 'View',
});
assert.equal(reconcilePluginViewTabCatalog({
loading: true,
plugins: [],
store,
}), false);
assert.deepEqual(store.getTabs().map((candidate) => candidate.id), [tab.id]);
assert.equal(activeTabId, tab.id);
const plugins = [{
id: 'publisher.plugin',
displayName: 'Plugin',
views: [{
id: 'publisher.plugin.view',
title: 'Localized View',
location: 'tab',
}],
}] as unknown as NetcattyPluginContributionSnapshot['plugins'];
assert.equal(reconcilePluginViewTabCatalog({
loading: false,
plugins,
store,
}), true);
assert.deepEqual(store.getTabs().map((candidate) => candidate.id), [tab.id]);
assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id);
});
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});

View File

@@ -0,0 +1,321 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
value: T;
}
export interface PluginViewTabCatalogEntry {
pluginId: string;
pluginName: string;
viewId: string;
title: string;
icon?: NetcattyPluginIconReference;
}
export interface PluginViewTabCatalogStore {
retain(viewIds: ReadonlySet<string>): void;
refreshMetadata(entries: readonly PluginViewTabCatalogEntry[]): void;
}
export function resolvePluginViewSnapshotSelection<T>({
resolved,
previous,
loading,
requestedViewId,
contextKey,
}: {
resolved: T | null;
previous: PluginViewSnapshotSelection<T> | null;
loading: boolean;
requestedViewId: string | undefined;
contextKey: string;
}): T | null {
if (resolved) return resolved;
if (!loading || !previous || previous.requestViewId !== requestedViewId
|| previous.contextKey !== contextKey) return null;
return previous.value;
}
export function shouldReconcilePluginViewTabCatalog({
loading,
}: {
loading: boolean;
}): boolean {
return !loading;
}
export function collectPluginViewTabCatalog(
plugins: NetcattyPluginContributionSnapshot['plugins'],
): PluginViewTabCatalogEntry[] {
return plugins.flatMap((plugin) => plugin.views
.filter((view) => view.location === 'tab')
.map((view) => ({
pluginId: plugin.id,
pluginName: plugin.displayName,
viewId: view.id,
title: view.title,
icon: view.icon,
})));
}
export function reconcilePluginViewTabCatalog({
loading,
plugins,
store,
}: {
loading: boolean;
plugins: NetcattyPluginContributionSnapshot['plugins'];
store: PluginViewTabCatalogStore;
}): boolean {
if (!shouldReconcilePluginViewTabCatalog({ loading })) return false;
const entries = collectPluginViewTabCatalog(plugins);
store.retain(new Set(entries.map((entry) => entry.viewId)));
store.refreshMetadata(entries);
return true;
}
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
test('plugin view window scopes distinguish path, query, and hash routes', () => {
const first = resolvePluginViewWindowScope({
pathname: '/index.html',
search: '?workspace=one',
hash: '#/settings',
});
const second = resolvePluginViewWindowScope({
pathname: '/index.html',
search: '?workspace=one',
hash: '#/terminal',
});
assert.equal(first, 'window:/index.html?workspace=one#/settings');
assert.notEqual(first, second);
});
test('plugin view window scopes remain deterministic and bounded for long routes', () => {
const route = {
pathname: '/index.html',
search: '',
hash: `#/${'nested/'.repeat(80)}`,
};
const scope = resolvePluginViewWindowScope(route);
assert.equal(scope, resolvePluginViewWindowScope(route));
assert.ok(scope.length <= 256);
assert.ok(!scope.includes('\0'));
assert.notEqual(scope, resolvePluginViewWindowScope({ ...route, hash: `${route.hash}different` }));
});
test('retained plugin views are isolated by both view and route scope', () => {
assert.notEqual(
resolvePluginRetainedViewKey('com.example.view', 'window:/index.html#/one'),
resolvePluginRetainedViewKey('com.example.view', 'window:/index.html#/two'),
);
assert.equal(canRetainPluginViewInScope('window:/index.html#/one', 'window:/index.html#/one'), true);
assert.equal(canRetainPluginViewInScope('window:/index.html#/one', 'window:/index.html#/two'), false);
});

View File

@@ -0,0 +1,31 @@
const MAX_PLUGIN_VIEW_SCOPE_ID_LENGTH = 256;
const MAX_INLINE_ROUTE_LENGTH = MAX_PLUGIN_VIEW_SCOPE_ID_LENGTH - 'window:'.length;
function hashRoute(value: string): string {
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193);
second = Math.imul(second ^ code, 0x85ebca6b);
}
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
}
export function resolvePluginViewWindowScope(
location: Pick<Location, 'pathname' | 'search' | 'hash'>,
): string {
const route = `${location.pathname || '/'}${location.search || ''}${location.hash || ''}`;
if (route.length <= MAX_INLINE_ROUTE_LENGTH && !route.includes('\0')) {
return `window:${route}`;
}
return `window:route:${hashRoute(route)}`;
}
export function resolvePluginRetainedViewKey(viewId: string, scopeId: string): string {
return JSON.stringify([viewId, scopeId]);
}
export function canRetainPluginViewInScope(viewScopeId: string, currentScopeId: string): boolean {
return viewScopeId === currentScopeId;
}

View File

@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewTabStore,
resolveBatchTabCloseFocus,
resolvePluginViewRequest,
toPluginViewTabId,
} from './pluginViewTabStore.ts';
function fixture() {
let activeTabId = 'vault';
const store = new PluginViewTabStore({
getActiveTabId: () => activeTabId,
setActiveTabId: (next) => { activeTabId = next; },
});
return { store, getActiveTabId: () => activeTabId };
}
test('plugin view tab IDs include both plugin and contribution ownership', () => {
assert.equal(
toPluginViewTabId('com.example.owner', 'shared.view'),
'plugin-view:com.example.owner:shared.view',
);
});
test('closing or withdrawing an active plugin view tab returns focus to a safe root page', () => {
const first = fixture();
const tab = first.store.open({
pluginId: 'com.example.owner',
pluginName: 'Owner',
viewId: 'com.example.owner.view',
title: 'View',
});
assert.equal(first.getActiveTabId(), tab.id);
first.store.close(tab.id);
assert.equal(first.getActiveTabId(), 'vault');
const second = fixture();
second.store.open({
pluginId: 'com.example.owner',
pluginName: 'Owner',
viewId: 'com.example.owner.view',
title: 'View',
});
second.store.retain(new Set());
assert.equal(second.getActiveTabId(), 'vault');
});
test('tab withdrawal notifies lifecycle owners for explicit close and contribution removal', () => {
const { store } = fixture();
const closed: string[] = [];
store.onDidClose(({ tab }) => closed.push(tab.id));
const first = store.open({
pluginId: 'com.example.owner',
pluginName: 'Owner',
viewId: 'com.example.owner.first',
title: 'First',
});
store.close(first.id);
const second = store.open({
pluginId: 'com.example.owner',
pluginName: 'Owner',
viewId: 'com.example.owner.second',
title: 'Second',
});
store.retain(new Set());
assert.deepEqual(closed, [first.id, second.id]);
});
test('localized contribution snapshots refresh titles and icons of already-open tabs', () => {
const { store } = fixture();
const tab = store.open({
pluginId: 'com.example.owner',
pluginName: 'Owner',
viewId: 'com.example.owner.view',
title: 'View',
icon: { kind: 'theme', name: 'terminal' },
context: { source: 'menu' },
});
store.refreshMetadata([{
pluginId: 'com.example.owner',
pluginName: '所有者',
viewId: 'com.example.owner.view',
title: '视图',
icon: { kind: 'theme', name: 'layout-panel' },
}]);
assert.deepEqual(store.getTab(tab.id), {
...tab,
pluginName: '所有者',
title: '视图',
icon: { kind: 'theme', name: 'layout-panel' },
});
});
test('an explicit open request takes precedence over the currently active plugin tab', () => {
const activeTab = { viewId: 'com.example.current', context: { source: 'tab' } };
assert.deepEqual(resolvePluginViewRequest({
viewId: 'com.example.requested',
context: { source: 'menu' },
}, activeTab), {
viewId: 'com.example.requested',
context: { source: 'menu' },
});
assert.deepEqual(resolvePluginViewRequest(null, activeTab), activeTab);
});
test('mixed batch closes focus the nearest tab that remains after every target is removed', () => {
const pluginTab = toPluginViewTabId('com.example.owner', 'com.example.owner.view');
assert.equal(resolveBatchTabCloseFocus({
orderedTabIds: ['session-1', pluginTab, 'workspace-1', 'session-2'],
closingTabIds: new Set(['session-1', pluginTab, 'workspace-1']),
activeTabId: pluginTab,
}), 'session-2');
assert.equal(resolveBatchTabCloseFocus({
orderedTabIds: ['session-1', pluginTab],
closingTabIds: new Set(['session-1', pluginTab]),
activeTabId: pluginTab,
}), 'vault');
});

View File

@@ -0,0 +1,147 @@
import { useSyncExternalStore } from 'react';
import { activeTabStore } from './activeTabStore';
export const PLUGIN_VIEW_TAB_PREFIX = 'plugin-view:';
export interface PluginViewTab {
id: string;
pluginId: string;
pluginName: string;
viewId: string;
title: string;
icon?: NetcattyPluginIconReference;
context?: Record<string, unknown>;
}
export interface ClosedPluginViewTabEvent {
tab: PluginViewTab;
}
export function toPluginViewTabId(pluginId: string, viewId: string): string {
return `${PLUGIN_VIEW_TAB_PREFIX}${pluginId}:${viewId}`;
}
export function isPluginViewTabId(tabId: string): boolean {
return tabId.startsWith(PLUGIN_VIEW_TAB_PREFIX);
}
export function resolveBatchTabCloseFocus({
orderedTabIds,
closingTabIds,
activeTabId,
}: {
orderedTabIds: readonly string[];
closingTabIds: ReadonlySet<string>;
activeTabId: string;
}): string {
if (!closingTabIds.has(activeTabId)) return activeTabId;
const activeIndex = orderedTabIds.indexOf(activeTabId);
if (activeIndex === -1) return 'vault';
for (let distance = 1; distance < orderedTabIds.length; distance += 1) {
const left = orderedTabIds[activeIndex - distance];
if (left && !closingTabIds.has(left)) return left;
const right = orderedTabIds[activeIndex + distance];
if (right && !closingTabIds.has(right)) return right;
}
return 'vault';
}
export function resolvePluginViewRequest(
requested: { viewId: string; context?: Record<string, unknown> } | null,
activeTab: Pick<PluginViewTab, 'viewId' | 'context'> | null,
): { viewId: string; context?: Record<string, unknown> } | null {
if (requested) return requested;
return activeTab ? { viewId: activeTab.viewId, context: activeTab.context } : null;
}
export class PluginViewTabStore {
private tabs: readonly PluginViewTab[] = Object.freeze([]);
private listeners = new Set<() => void>();
private closeListeners = new Set<(event: ClosedPluginViewTabEvent) => void>();
constructor(private readonly activeTabs: Pick<typeof activeTabStore, 'getActiveTabId' | 'setActiveTabId'> = activeTabStore) {}
getTabs = () => this.tabs;
getTab(tabId: string): PluginViewTab | undefined {
return this.tabs.find((tab) => tab.id === tabId);
}
open(input: Omit<PluginViewTab, 'id'>): PluginViewTab {
const id = toPluginViewTabId(input.pluginId, input.viewId);
const tab = Object.freeze({ ...input, id });
const index = this.tabs.findIndex((candidate) => candidate.id === id);
this.tabs = Object.freeze(index === -1
? [...this.tabs, tab]
: this.tabs.map((candidate) => candidate.id === id ? tab : candidate));
this.emit();
this.activeTabs.setActiveTabId(id);
return tab;
}
close(tabId: string): void {
const closed = this.tabs.find((tab) => tab.id === tabId);
if (!closed) return;
const next = this.tabs.filter((tab) => tab.id !== tabId);
this.tabs = Object.freeze(next);
if (this.activeTabs.getActiveTabId() === tabId) this.activeTabs.setActiveTabId('vault');
this.emit();
this.emitClosed(closed);
}
retain(viewIds: ReadonlySet<string>): void {
const next = this.tabs.filter((tab) => viewIds.has(tab.viewId));
if (next.length === this.tabs.length) return;
const removed = this.tabs.filter((tab) => !viewIds.has(tab.viewId));
const activeTabId = this.activeTabs.getActiveTabId();
this.tabs = Object.freeze(next);
if (isPluginViewTabId(activeTabId) && !next.some((tab) => tab.id === activeTabId)) {
this.activeTabs.setActiveTabId('vault');
}
this.emit();
for (const tab of removed) this.emitClosed(tab);
}
refreshMetadata(entries: readonly Omit<PluginViewTab, 'id' | 'context'>[]): void {
const metadata = new Map(entries.map((entry) => [toPluginViewTabId(entry.pluginId, entry.viewId), entry]));
let changed = false;
const next = this.tabs.map((tab) => {
const entry = metadata.get(tab.id);
if (!entry) return tab;
if (entry.pluginName === tab.pluginName
&& entry.title === tab.title
&& JSON.stringify(entry.icon ?? null) === JSON.stringify(tab.icon ?? null)) return tab;
changed = true;
return Object.freeze({ ...tab, ...entry, id: tab.id });
});
if (!changed) return;
this.tabs = Object.freeze(next);
this.emit();
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
onDidClose = (listener: (event: ClosedPluginViewTabEvent) => void) => {
this.closeListeners.add(listener);
return () => this.closeListeners.delete(listener);
};
private emit(): void {
for (const listener of this.listeners) listener();
}
private emitClosed(tab: PluginViewTab): void {
const event = Object.freeze({ tab });
for (const listener of this.closeListeners) listener(event);
}
}
export const pluginViewTabStore = new PluginViewTabStore();
export function usePluginViewTabs(): readonly PluginViewTab[] {
return useSyncExternalStore(pluginViewTabStore.subscribe, pluginViewTabStore.getTabs, pluginViewTabStore.getTabs);
}

View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAiSidePanelToggleIntent } from "./resolveAiSidePanelToggleIntent.ts";
test("close: AI panel already open → close the side panel", () => {
const r = resolveAiSidePanelToggleIntent("ai");
assert.deepEqual(r, { kind: "closeTerminalSidePanel" });
});
test("open: no panel open → open AI", () => {
const r = resolveAiSidePanelToggleIntent(null);
assert.deepEqual(r, { kind: "openAi" });
});
test("open: a different sub-panel is open → switch to AI", () => {
assert.deepEqual(resolveAiSidePanelToggleIntent("sftp"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("scripts"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("theme"), { kind: "openAi" });
});

View File

@@ -0,0 +1,19 @@
export type AiSidePanelToggleIntent =
| { kind: 'closeTerminalSidePanel' }
| { kind: 'openAi' };
/**
* Decide what the top-bar AI button should do given the side panel that is
* currently open for the active tab.
* - If the AI panel is already the open sub-panel → close the whole side panel.
* - Otherwise (closed, or showing a different sub-panel) → switch to AI.
*/
export function resolveAiSidePanelToggleIntent(
activePanel: string | null,
): AiSidePanelToggleIntent {
if (activePanel === 'ai') {
return { kind: 'closeTerminalSidePanel' };
}
return { kind: 'openAi' };
}

View File

@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
const r = resolveCloseIntent({
activeTabId: "vault",
workspace: null,
sessionForTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});

View File

@@ -0,0 +1,34 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}
if (!workspace) {
// e.g. 'vault', 'sftp', or any non-closable pinned tab
return { kind: 'noop' };
}
const focusedSessionId = workspace.focusedSessionId;
if (focusedSessionId && focusIsInsideTerminal) {
return { kind: 'closeTerminal', sessionId: focusedSessionId };
}
return { kind: 'closeWorkspace', workspaceId: workspace.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveScriptsSidePanelShortcutIntent,
resolveSnippetsShortcutIntent,
} from "./resolveSnippetsShortcutIntent.ts";
test("active single terminal tab toggles the terminal scripts panel", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "s1",
sessionForTab: { id: "s1" },
workspaceForTab: null,
});
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
});
test("active workspace tab toggles the terminal scripts panel", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "w1",
sessionForTab: null,
workspaceForTab: { id: "w1" },
});
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
});
test("non-terminal tabs navigate to the vault snippets section", () => {
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
const result = resolveSnippetsShortcutIntent({
activeTabId,
sessionForTab: null,
workspaceForTab: null,
});
assert.deepEqual(result, { kind: "openVaultSnippets" });
}
});
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "s1",
sessionForTab: { id: "s1" },
workspaceForTab: null,
terminalScriptsToggleAvailable: false,
});
assert.deepEqual(result, { kind: "openVaultSnippets" });
});
test("scripts panel shortcut closes when scripts is already open", () => {
const result = resolveScriptsSidePanelShortcutIntent("scripts");
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
});
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
for (const activePanel of [null, "sftp", "theme", "ai"]) {
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
assert.deepEqual(result, { kind: "openTerminalScripts" });
}
});

View File

@@ -0,0 +1,42 @@
export type SnippetsShortcutIntent =
| { kind: 'toggleTerminalScripts' }
| { kind: 'openVaultSnippets' };
export type ScriptsSidePanelShortcutIntent =
| { kind: 'closeTerminalSidePanel' }
| { kind: 'openTerminalScripts' };
export interface ResolveSnippetsShortcutIntentInput {
activeTabId: string | null;
sessionForTab: { id: string } | null;
workspaceForTab: { id: string } | null;
terminalScriptsToggleAvailable?: boolean;
}
export function resolveSnippetsShortcutIntent(
input: ResolveSnippetsShortcutIntentInput,
): SnippetsShortcutIntent {
const {
activeTabId,
sessionForTab,
workspaceForTab,
terminalScriptsToggleAvailable = true,
} = input;
if (!activeTabId) return { kind: 'openVaultSnippets' };
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
return { kind: 'toggleTerminalScripts' };
}
return { kind: 'openVaultSnippets' };
}
export function resolveScriptsSidePanelShortcutIntent(
activePanel: string | null,
): ScriptsSidePanelShortcutIntent {
if (activePanel === 'scripts') {
return { kind: 'closeTerminalSidePanel' };
}
return { kind: 'openTerminalScripts' };
}

View File

@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
);
});
test("disabled auto-close keeps the session tab after a clean exit", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, false),
{ kind: "markDisconnected" },
);
});
test("non-zero backend exits keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 1 }),
{ kind: "markDisconnected" },
);
});
test("backend exits without a confirmed clean exit code keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited" }),
{ kind: "markDisconnected" },
);
});
test("backend timeout events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
assert.equal(
shouldCloseTerminalPopupOnExit(
{ reason: "exited", exitCode: 0 },
{ autoCloseOnExit: false },
),
false,
);
assert.equal(
shouldCloseTerminalPopupOnExit(
{ reason: "closed", exitCode: 0 },
{ autoCloseOnExit: false, isAttachMode: true },
),
false,
);
});
test("disabled auto-close reveals every command popup exit instead of reporting startup failure", () => {
assert.equal(
shouldRevealTerminalPopupOnExit(
{ reason: "exited", exitCode: 0 },
{ autoCloseOnExit: false },
),
true,
);
assert.equal(
shouldRevealTerminalPopupOnExit(
{ reason: "exited", exitCode: 1 },
{ autoCloseOnExit: false },
),
true,
);
assert.equal(
shouldRevealTerminalPopupOnExit(
{ reason: "exited" },
{ autoCloseOnExit: false },
),
true,
);
assert.equal(
shouldRevealTerminalPopupOnExit(
{ reason: "exited", exitCode: 0 },
{ autoCloseOnExit: false, isAttachMode: true },
),
false,
);
});
test("attached terminal popups keep their existing auto-close behavior by default", () => {
assert.equal(
shouldCloseTerminalPopupOnExit(
{ reason: "closed", exitCode: 0 },
{ isAttachMode: true },
),
true,
);
});

View File

@@ -0,0 +1,51 @@
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
type TerminalPopupExitOptions = {
autoCloseOnExit?: boolean;
isAttachMode?: boolean;
};
function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
return evt.reason === "exited" && evt.exitCode === 0;
}
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
autoCloseOnExit = true,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
}
// Non-zero or unknown exits, timeouts, transport errors, and channel closes
// should keep the tab visible so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
export function shouldCloseTerminalPopupOnExit(
evt: TerminalSessionExitEvent,
options: TerminalPopupExitOptions = {},
): boolean {
if (options.autoCloseOnExit === false) return false;
return options.isAttachMode === true || isConfirmedCleanExit(evt);
}
export function shouldRevealTerminalPopupOnExit(
_evt: TerminalSessionExitEvent,
options: TerminalPopupExitOptions = {},
): boolean {
return options.autoCloseOnExit === false &&
options.isAttachMode !== true;
}

View File

@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
setScriptRuns([{
runId,
scriptId: 's1',
sessionId: 'sess1',
status: 'completed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
logs: [],
}]);
const run = await waitForScriptRun(runId, { timeoutMs: 5000 });
assert.equal(run.runId, runId);
assert.equal(run.status, 'completed');
});
test('waitForScriptRun rejects when run already failed on subscribe', async () => {
const runId = 'run-already-failed';
setScriptRuns([{
runId,
sessionId: 'sess1',
status: 'failed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
error: 'boom',
logs: [],
}]);
await assert.rejects(
() => waitForScriptRun(runId, { timeoutMs: 5000 }),
/boom/,
);
});
test('selectScriptOverlayRun does not resurface older completed runs after dismissal', () => {
const dismissedRunIds = new Set<string>();
const completed = (runId: string, endedAt: number): ScriptRun => ({
runId,
sessionId: 'sess1',
status: 'completed',
startedAt: endedAt - 100,
endedAt,
logs: [],
});
const olderRun = completed('older-run', 1_000);
const latestRun = completed('latest-run', 2_000);
assert.equal(
selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds)?.runId,
latestRun.runId,
);
assert.ok(dismissedRunIds.has(olderRun.runId));
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
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, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});

View File

@@ -0,0 +1,288 @@
import type { Snippet } from '@/domain/models';
import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
import type { AIPermissionMode } from '@/infrastructure/ai/types.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import { publishScriptRunsSnapshot } from './scriptRunsStore.ts';
type RunsListener = (runs: ScriptRun[]) => void;
let runs: ScriptRun[] = [];
const runsListeners = new Set<RunsListener>();
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored;
return 'confirm';
}
export function subscribeScriptRuns(listener: RunsListener): () => void {
runsListeners.add(listener);
queueMicrotask(() => {
if (runsListeners.has(listener)) {
listener(runs);
}
});
return () => runsListeners.delete(listener);
}
export function getScriptRuns(): readonly ScriptRun[] {
return runs;
}
export function setScriptRuns(nextRuns: ScriptRun[]) {
runs = nextRuns;
// Keep the panel-facing store in lockstep so Scripts UI and overlays share one source.
publishScriptRunsSnapshot(nextRuns);
runsListeners.forEach((listener) => listener(runs));
}
/**
* Chooses the single overlay worth showing while retaining dismissed history
* so global run broadcasts cannot bring old completion banners back.
*/
export function selectScriptOverlayRun(
allRuns: ScriptRun[],
sessionId: string,
dismissedRunIds: Set<string>,
): ScriptRun | undefined {
const sessionRuns = allRuns.filter((run) => run.sessionId === sessionId);
const activeRunIds = new Set(sessionRuns.map((run) => run.runId));
for (const runId of dismissedRunIds) {
if (!activeRunIds.has(runId)) dismissedRunIds.delete(runId);
}
const liveRun = sessionRuns.find((run) => run.status === 'running' || run.status === 'paused');
if (liveRun) {
for (const run of sessionRuns) {
if (run.status === 'completed' || run.status === 'failed') {
dismissedRunIds.add(run.runId);
}
}
return liveRun;
}
const finishedRuns = sessionRuns
.filter((run) => run.status === 'completed' || run.status === 'failed')
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
const latestRun = finishedRuns.find((run) => !dismissedRunIds.has(run.runId));
if (!latestRun) return undefined;
for (const run of finishedRuns) {
if (run.runId !== latestRun.runId) dismissedRunIds.add(run.runId);
}
return latestRun;
}
export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined {
return runs.find((run) =>
run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'),
);
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
mode?: 'sequential' | 'parallel';
sessionMeta?: {
connected?: boolean;
name?: string;
hostname?: string;
username?: string;
};
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = readPermissionMode();
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
const bridge = netcattyBridge.get();
if (!bridge?.scriptRun) {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
sessionId: params.sessionId,
sessionIds: params.sessionIds,
mode: params.mode,
permissionMode,
sessionMeta: params.sessionMeta,
});
}
const TERMINAL_SCRIPT_STATUSES = new Set<ScriptRun['status']>(['completed', 'failed']);
export function waitForScriptRun(
runId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ScriptRun> {
const existing = runs.find((entry) => entry.runId === runId);
if (existing && TERMINAL_SCRIPT_STATUSES.has(existing.status)) {
if (existing.status === 'completed') {
return Promise.resolve(existing);
}
return Promise.reject(new Error(existing.error || 'Script failed'));
}
const timeoutMs = options.timeoutMs ?? 3_600_000;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: () => void = () => {};
const finish = (handler: () => void) => {
if (settled) return;
settled = true;
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
unsubscribe();
options.signal?.removeEventListener('abort', onAbort);
handler();
};
const onAbort = () => {
finish(() => reject(new Error('Aborted')));
};
const settleRun = (run: ScriptRun | undefined) => {
if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return;
if (run.status === 'completed') {
finish(() => resolve(run));
return;
}
finish(() => reject(new Error(run.error || 'Script failed')));
};
unsubscribe = subscribeScriptRuns((currentRuns) => {
settleRun(currentRuns.find((entry) => entry.runId === runId));
});
timeoutId = setTimeout(() => {
finish(() => reject(new Error('Script run timed out')));
}, timeoutMs);
options.signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
connected?: boolean;
name?: string;
hostname?: string;
username?: string;
};
}): Promise<void> {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
export async function runSnippetOrScript(params: {
snippet: Snippet;
sessionId: string;
runSnippetText: (
command: string,
noAutoRun?: boolean,
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
) => void;
command: string;
}) {
if (isScriptSnippet(params.snippet)) {
await runAutomationScript({
snippet: params.snippet,
sessionId: params.sessionId,
});
return;
}
params.runSnippetText(params.command, params.snippet.noAutoRun, {
multiLineRunMode: params.snippet.multiLineRunMode,
});
}
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptStop?.(runId);
return { ok: result?.ok !== false };
}
export async function pauseScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptPause?.(runId);
return { ok: result?.ok !== false };
}
export async function resumeScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptResume?.(runId);
return { ok: result?.ok !== false };
}

View File

@@ -0,0 +1,30 @@
type ScriptRecordingSnapshot = {
sessionId: string | null;
isPaused: boolean;
};
type Listener = () => void;
let snapshot: ScriptRecordingSnapshot = { sessionId: null, isPaused: false };
const listeners = new Set<Listener>();
function emit() {
for (const listener of listeners) {
listener();
}
}
export function getScriptRecordingSnapshot(): ScriptRecordingSnapshot {
return snapshot;
}
export function subscribeScriptRecording(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setScriptRecordingState(sessionId: string | null, isPaused = false): void {
if (snapshot.sessionId === sessionId && snapshot.isPaused === isPaused) return;
snapshot = { sessionId, isPaused };
emit();
}

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setScriptRuns, getScriptRuns } from './scriptAutomationCoordinator.ts';
import {
getScriptRunsSnapshot,
subscribeScriptRuns,
} from './scriptRunsStore.ts';
test('setScriptRuns publishes to scriptRunsStore for Scripts panel subscribers', () => {
const events: number[] = [];
const unsubscribe = subscribeScriptRuns(() => {
events.push(getScriptRunsSnapshot().length);
});
setScriptRuns([
{
runId: 'r1',
sessionId: 's1',
status: 'running',
startedAt: 1,
logs: [],
},
]);
assert.equal(getScriptRuns().length, 1);
assert.equal(getScriptRunsSnapshot().length, 1);
assert.equal(getScriptRunsSnapshot()[0]?.runId, 'r1');
assert.ok(events.includes(1));
setScriptRuns([]);
assert.equal(getScriptRunsSnapshot().length, 0);
unsubscribe();
});

View File

@@ -0,0 +1,43 @@
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
type Listener = () => void;
/**
* External store for script automation runs so Scripts side panel can update
* without forcing TerminalLayerInner re-renders on every log/status tick.
*/
class ScriptRunsStore {
private snapshot: readonly ScriptRun[] = [];
private listeners = new Set<Listener>();
getSnapshot = (): readonly ScriptRun[] => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: readonly ScriptRun[]): void {
if (this.snapshot === next) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const scriptRunsStore = new ScriptRunsStore();
export function publishScriptRunsSnapshot(runs: readonly ScriptRun[]): void {
scriptRunsStore.setSnapshot(runs);
}
export function getScriptRunsSnapshot(): readonly ScriptRun[] {
return scriptRunsStore.getSnapshot();
}
export function subscribeScriptRuns(listener: Listener): () => void {
return scriptRunsStore.subscribe(listener);
}

View File

@@ -0,0 +1,46 @@
import { TerminalSession } from '../../types';
type SessionActivityMap = Record<string, boolean>;
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
return new Set(sessions.map((session) => session.id));
};
export const shouldMarkSessionActivity = (
activeTabId: string | null,
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
): boolean => {
return activeTabId !== session.id && activeTabId !== session.workspaceId;
};
export const getSessionActivityIdsToClear = (
activeTabId: string | null,
sessions: TerminalSession[],
): string[] => {
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
return [];
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) {
return [activeSession.id];
}
return sessions
.filter((session) => session.workspaceId === activeTabId)
.map((session) => session.id);
};
export const buildWorkspaceActivityMap = (
sessions: TerminalSession[],
sessionActivityMap: SessionActivityMap,
): Map<string, boolean> => {
const workspaceActivityMap = new Map<string, boolean>();
for (const session of sessions) {
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
workspaceActivityMap.set(session.workspaceId, true);
}
return workspaceActivityMap;
};

View File

@@ -0,0 +1,112 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class SessionActivityStore {
private snapshot: Record<string, boolean> = {};
private listeners = new Set<Listener>();
getSnapshot = () => this.snapshot;
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private emit() {
this.listeners.forEach((listener) => listener());
}
setTabActive = (tabId: string, hasActivity: boolean) => {
const alreadyActive = !!this.snapshot[tabId];
if (alreadyActive === hasActivity) return;
if (hasActivity) {
this.snapshot = { ...this.snapshot, [tabId]: true };
} else {
const { [tabId]: _removed, ...rest } = this.snapshot;
this.snapshot = rest;
}
this.emit();
};
clearTab = (tabId: string) => {
this.setTabActive(tabId, false);
};
clearTabs = (tabIds: Iterable<string>) => {
let changed = false;
const next = { ...this.snapshot };
for (const tabId of tabIds) {
if (!next[tabId]) continue;
delete next[tabId];
changed = true;
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
prune = (validTabIds: Set<string>) => {
let changed = false;
const next: Record<string, boolean> = {};
for (const tabId of Object.keys(this.snapshot)) {
if (validTabIds.has(tabId)) {
next[tabId] = true;
} else {
changed = true;
}
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
}
export const sessionActivityStore = new SessionActivityStore();
export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
sessionActivityStore.getSnapshot,
);
};
/**
* Per-tab activity boolean. Store notify is still global, but React skips re-render
* when this tab's boolean is unchanged (Object.is on the snapshot primitive).
*/
export const useSessionActivity = (tabId: string): boolean => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
() => !!sessionActivityStore.getSnapshot()[tabId],
() => !!sessionActivityStore.getSnapshot()[tabId],
);
};
/** True if any of the given session ids currently has activity. */
export const useAnySessionActivity = (sessionIds: readonly string[]): boolean => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
() => {
const snap = sessionActivityStore.getSnapshot();
for (const id of sessionIds) {
if (snap[id]) return true;
}
return false;
},
() => {
const snap = sessionActivityStore.getSnapshot();
for (const id of sessionIds) {
if (snap[id]) return true;
}
return false;
},
);
};

View File

@@ -0,0 +1,86 @@
import type { SessionCapabilities } from '../../domain/systemManager/types';
/** Internal entry: capabilities plus computed expiry timestamp. */
interface StoreEntry {
capabilities: SessionCapabilities;
expiresAt: number;
}
type Listener = () => void;
const capabilitiesBySessionId = new Map<string, StoreEntry>();
const listenersBySessionId = new Map<string, Set<Listener>>();
function isExpired(entry: StoreEntry): boolean {
return Date.now() > entry.expiresAt;
}
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
return {
...capabilities,
hasSs: capabilities.hasSs === true,
hasNetstat: capabilities.hasNetstat === true,
hasLsof: capabilities.hasLsof === true,
hasSystemctl: capabilities.hasSystemctl === true,
};
}
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
export const sessionCapabilitiesStore = {
get(sessionId: string): SessionCapabilities | undefined {
const entry = capabilitiesBySessionId.get(sessionId);
if (!entry) return undefined;
if (isExpired(entry)) {
capabilitiesBySessionId.delete(sessionId);
notifySession(sessionId);
return undefined;
}
return normalizeCapabilities(entry.capabilities);
},
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...normalizeCapabilities(capabilities),
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
};
capabilitiesBySessionId.set(sessionId, entry);
notifySession(sessionId);
},
delete(sessionId: string) {
if (!capabilitiesBySessionId.delete(sessionId)) return;
notifySession(sessionId);
listenersBySessionId.delete(sessionId);
},
/** Drop cached capabilities for sessions that no longer exist. */
prune(liveSessionIds: ReadonlySet<string>) {
for (const sessionId of capabilitiesBySessionId.keys()) {
if (!liveSessionIds.has(sessionId)) {
capabilitiesBySessionId.delete(sessionId);
listenersBySessionId.delete(sessionId);
}
}
},
subscribe(sessionId: string, listener: Listener): () => void {
let set = listenersBySessionId.get(sessionId);
if (!set) {
set = new Set();
listenersBySessionId.set(sessionId, set);
}
set.add(listener);
return () => {
set?.delete(listener);
if (set && set.size === 0) {
listenersBySessionId.delete(sessionId);
}
};
},
};

View File

@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LOCAL_TERMINAL_HOST_ID,
createLocalTerminalSession,
} from "./sessionFactories.ts";
test("createLocalTerminalSession uses a stable hostId across sessions", () => {
const a = createLocalTerminalSession("session-a");
const b = createLocalTerminalSession("session-b");
assert.equal(a.hostId, LOCAL_TERMINAL_HOST_ID);
assert.equal(b.hostId, LOCAL_TERMINAL_HOST_ID);
assert.notEqual(a.id, b.id);
assert.equal(a.protocol, "local");
});

View File

@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
createWorkspaceHostTerminalSession,
} from "./sessionFactories";
const host = (overrides: Partial<Host>): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "alice",
port: 22,
group: "",
tags: [],
os: "linux",
protocol: "ssh",
createdAt: 1,
...overrides,
});
test("createHostTerminalSession keeps telnet deep-link default port for ssh hosts with telnet enabled", () => {
const connectionHost = buildTelnetDeepLinkConnectionHost(
host({
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
}),
);
const session = createHostTerminalSession("session-1", connectionHost);
assert.equal(session.protocol, "telnet");
assert.equal(session.port, 23);
});
test("serial session factories snapshot effective legacy Backspace behavior", () => {
const savedHostSession = createHostTerminalSession("session-1", host({
protocol: "serial",
hostname: "COM3",
port: 115200,
username: "",
serialConfig: {
path: "COM3",
baudRate: 115200,
},
backspaceBehavior: "ctrl-h",
}));
const quickSession = createSerialTerminalSession("session-2", {
path: "COM4",
baudRate: 9600,
});
const explicitDefaultSession = createHostTerminalSession("session-3", host({
protocol: "serial",
hostname: "COM5",
port: 115200,
username: "",
backspaceBehavior: "ctrl-h",
serialConfig: {
path: "COM5",
baudRate: 115200,
backspaceBehavior: "default",
},
}));
assert.equal(savedHostSession.serialConfig?.backspaceBehavior, "ctrl-h");
assert.equal(quickSession.serialConfig?.backspaceBehavior, "default");
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
});
test("workspace host factory creates a complete serial session", () => {
const session = createWorkspaceHostTerminalSession("session-serial", host({
protocol: "serial",
hostname: "COM7",
port: 57600,
username: "",
serialConfig: {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
},
}), "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.protocol, "serial");
assert.deepEqual(session.serialConfig, {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
backspaceBehavior: "default",
});
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
hostname: "COM3",
port: 115200,
username: "",
group: "network/serial",
serialConfig: prepareSerialConfigForSavedHost({
path: "COM3",
baudRate: 115200,
backspaceBehavior: "default",
}),
});
const effectiveHost = resolveEffectiveTerminalHost({
host: savedHost,
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
proxyProfiles: [],
});
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
});
test("host session factories snapshot plugin connection configuration", () => {
const providerId = "com.example.transport.connection";
const pluginConnection = {
providerId,
authenticationProviderId: "com.example.transport.auth",
configuration: { endpoint: "gateway.example", tags: ["prod"] },
credentialId: "credential-reference-1234",
};
const pluginHost = host({
protocol: `plugin:${providerId}`,
pluginConnection,
});
const regular = createHostTerminalSession("session-plugin", pluginHost);
const workspace = createWorkspaceHostTerminalSession("session-workspace-plugin", pluginHost, "workspace-1");
assert.equal(regular.protocol, `plugin:${providerId}`);
assert.deepEqual(regular.pluginConnection, pluginConnection);
assert.notEqual(regular.pluginConnection, pluginConnection);
assert.equal(workspace.workspaceId, "workspace-1");
assert.deepEqual(workspace.pluginConnection, pluginConnection);
assert.notEqual(workspace.pluginConnection, pluginConnection);
});

View File

@@ -0,0 +1,128 @@
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
}
/**
* Stable hostId for all Local Terminal sessions.
*
* Autocomplete command history is keyed by hostId. Using `local-${sessionId}`
* made every new Local Terminal look like a brand-new host, so history
* suggestions never accumulated across opens (issue #2037).
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
): TerminalSession => ({
id: sessionId,
hostId: LOCAL_TERMINAL_HOST_ID,
hostLabel: options?.shellName || "Local Terminal",
hostname: "localhost",
username: "local",
status: "connecting",
protocol: "local",
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
localStartDir: options?.localStartDir,
});
export const snapshotSerialConfig = (
config: SerialConfig,
legacyBackspaceBehavior?: Host["backspaceBehavior"],
): SerialConfig => ({
...config,
backspaceBehavior: config.backspaceBehavior
?? (legacyBackspaceBehavior === "ctrl-h" ? "ctrl-h" : "default"),
});
export const createSerialTerminalSession = (
sessionId: string,
config: SerialConfig,
options?: { charset?: string },
): TerminalSession => {
const serialConfig = snapshotSerialConfig(config);
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
return {
id: sessionId,
hostId: `serial-${sessionId}`,
hostLabel: `Serial: ${portName}`,
hostname: serialConfig.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig,
charset: options?.charset,
};
};
export const createHostTerminalSession = (
sessionId: string,
host: Host,
): TerminalSession => {
if (host.protocol === "serial") {
const serialConfig = snapshotSerialConfig(
host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
host.backspaceBehavior,
);
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig,
charset: host.charset,
};
}
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: "connecting",
protocol: host.protocol,
pluginConnection: host.pluginConnection == null
? undefined
: structuredClone(host.pluginConnection),
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
};
export const createWorkspaceHostTerminalSession = (
sessionId: string,
host: Host,
workspaceId: string,
): TerminalSession => ({
...createHostTerminalSession(sessionId, host),
workspaceId,
});

View File

@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
retainStableSessionsIgnoringPresentation,
terminalPaneSessionsEqual,
} from '../../domain/terminalPaneSessionsEqual.ts';
import { topTabsSessionsEqual } from '../../domain/topTabsSessionsEqual.ts';
import {
applySessionPresentation,
publishSessionCodingCliProvider,
publishSessionDynamicTitle,
sessionPresentationStore,
} from './sessionPresentationStore.ts';
const session = (overrides: Record<string, unknown> = {}) => ({
id: 's1',
hostId: 'h1',
hostLabel: 'host',
username: 'root',
hostname: 'example.test',
status: 'connected' as const,
...overrides,
});
test('presentation store notifies on title and provider updates', () => {
sessionPresentationStore.clearSession('s1');
const versions: number[] = [];
const unsub = sessionPresentationStore.subscribe(() => {
versions.push(sessionPresentationStore.getVersion());
});
const base = sessionPresentationStore.getVersion();
publishSessionDynamicTitle('s1', 'Claude Code');
assert.equal(sessionPresentationStore.getPresentation('s1')?.dynamicTitle, 'Claude Code');
publishSessionCodingCliProvider('s1', 'claude');
assert.equal(sessionPresentationStore.getPresentation('s1')?.codingCliProviderId, 'claude');
assert.ok(sessionPresentationStore.getVersion() > base);
assert.ok(versions.length >= 2);
unsub();
sessionPresentationStore.clearSession('s1');
});
test('title-only session changes stay equal for TopTabs structural compare', () => {
const prev = [session({ dynamicTitle: 'old' })];
const next = [session({ dynamicTitle: 'new', codingCliProviderId: 'claude' })];
assert.equal(topTabsSessionsEqual(prev, next), true);
assert.equal(terminalPaneSessionsEqual(prev, next), true);
assert.equal(
topTabsSessionsEqual(prev, [session({ status: 'disconnected' })]),
false,
);
});
test('applySessionPresentation overlays store onto orphan-style snapshots', () => {
sessionPresentationStore.clearSession('orphan-1');
publishSessionDynamicTitle('orphan-1', 'agent: refactor');
publishSessionCodingCliProvider('orphan-1', 'claude');
const base = session({ id: 'orphan-1', dynamicTitle: 'stale', codingCliProviderId: undefined });
const merged = applySessionPresentation(base);
assert.equal(merged.dynamicTitle, 'agent: refactor');
assert.equal(merged.codingCliProviderId, 'claude');
// Same store values → retain object identity when already up to date.
assert.equal(applySessionPresentation(merged), merged);
sessionPresentationStore.clearSession('orphan-1');
});
test('retainStableSessionsIgnoringPresentation keeps array identity on title-only churn', () => {
const prev = [session({ dynamicTitle: 'old' })];
const next = [session({ dynamicTitle: 'new', codingCliProviderId: 'codex' })];
const retained = retainStableSessionsIgnoringPresentation(prev, next);
assert.equal(retained, prev);
const structural = [session({ status: 'disconnected' })];
assert.notEqual(retainStableSessionsIgnoringPresentation(prev, structural), prev);
});
test('applySessionPresentation is the shared overlay for focus sidebar and panes', () => {
sessionPresentationStore.clearSession('focus-1');
publishSessionDynamicTitle('focus-1', 'agent: search me');
const base = session({ id: 'focus-1', dynamicTitle: undefined });
const merged = applySessionPresentation(base);
assert.equal(merged.dynamicTitle, 'agent: search me');
assert.ok(merged.dynamicTitle?.toLowerCase().includes('search'));
sessionPresentationStore.clearSession('focus-1');
});
test('per-session snapshot stays stable when a sibling session title changes', () => {
sessionPresentationStore.clearSession('a');
sessionPresentationStore.clearSession('b');
publishSessionDynamicTitle('a', 'title-a');
const snapBBefore = sessionPresentationStore.getSessionSnapshot('b');
publishSessionDynamicTitle('a', 'title-a-2');
assert.equal(sessionPresentationStore.getSessionSnapshot('b'), snapBBefore);
publishSessionDynamicTitle('b', 'title-b');
assert.notEqual(sessionPresentationStore.getSessionSnapshot('b'), snapBBefore);
sessionPresentationStore.clearSession('a');
sessionPresentationStore.clearSession('b');
});
test('null presentation tombstones clear stale snapshot titles', () => {
sessionPresentationStore.clearSession('tomb-1');
// No prior store entry; first clear must still be persisted.
publishSessionDynamicTitle('tomb-1', null);
assert.equal(sessionPresentationStore.getPresentation('tomb-1')?.dynamicTitle, null);
const base = session({ id: 'tomb-1', dynamicTitle: 'stale-from-snapshot' });
const merged = applySessionPresentation(base);
assert.equal(merged.dynamicTitle, undefined);
// Idempotent second clear does not thrash.
const version = sessionPresentationStore.getVersion();
publishSessionDynamicTitle('tomb-1', null);
assert.equal(sessionPresentationStore.getVersion(), version);
sessionPresentationStore.clearSession('tomb-1');
});
test('session snapshot distinguishes missing fields from null tombstones', () => {
sessionPresentationStore.clearSession('snap-1');
sessionPresentationStore.setPresentation('snap-1', { dynamicTitle: 'live', codingCliProviderId: 'claude' });
const bothLive = sessionPresentationStore.getSessionSnapshot('snap-1');
publishSessionDynamicTitle('snap-1', null);
const titleTombstoned = sessionPresentationStore.getSessionSnapshot('snap-1');
assert.notEqual(titleTombstoned, bothLive);
// Clearing provider after title tombstone must also change the snapshot.
publishSessionCodingCliProvider('snap-1', null);
const bothTombstoned = sessionPresentationStore.getSessionSnapshot('snap-1');
assert.notEqual(bothTombstoned, titleTombstoned);
// Empty entry vs explicit dual null tombstone are distinct from missing.
assert.notEqual(bothTombstoned, '');
sessionPresentationStore.clearSession('snap-1');
});

View File

@@ -0,0 +1,163 @@
import { useSyncExternalStore } from 'react';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
export type SessionPresentation = {
dynamicTitle?: string | null;
codingCliProviderId?: CodingCliProviderId | null;
};
type Listener = () => void;
/** Encode undefined / null / present distinctly for useSyncExternalStore snapshots. */
function encodePresentationField(value: string | null | undefined): string {
if (value === undefined) return 'u';
if (value === null) return 'n';
return `v:${value}`;
}
/**
* Presentation-only session chrome (tab title / coding-CLI icon) separate from
* structural session identity used by TerminalLayer pane equality.
*/
class SessionPresentationStore {
private bySession = new Map<string, SessionPresentation>();
private version = 0;
private listeners = new Set<Listener>();
getVersion = (): number => this.version;
getPresentation = (sessionId: string): SessionPresentation | undefined =>
this.bySession.get(sessionId);
/**
* Stable per-session snapshot for useSyncExternalStore. Global listeners still
* fire on any change, but React skips re-render when this string is unchanged
* for the subscribed sessionId (Object.is).
*
* Encodes undefined / null / value distinctly so a null tombstone is not
* Object.is-equal to a missing field (both used to collapse to '').
*/
getSessionSnapshot = (sessionId: string): string => {
const presentation = this.bySession.get(sessionId);
if (!presentation) return '';
return `${encodePresentationField(presentation.dynamicTitle)}\0${encodePresentationField(presentation.codingCliProviderId)}`;
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setPresentation(sessionId: string, patch: SessionPresentation): void {
const prev = this.bySession.get(sessionId) ?? {};
const next: SessionPresentation = { ...prev, ...patch };
// Distinguish missing (undefined) from explicit clear (null) so the first
// tombstone is stored even when the session snapshot still has a stale
// title/provider and the store had no prior entry.
if (
prev.dynamicTitle === next.dynamicTitle
&& prev.codingCliProviderId === next.codingCliProviderId
) {
return;
}
this.bySession.set(sessionId, next);
this.version += 1;
for (const listener of this.listeners) listener();
}
clearSession(sessionId: string): void {
if (!this.bySession.has(sessionId)) return;
this.bySession.delete(sessionId);
this.version += 1;
for (const listener of this.listeners) listener();
}
prune(validSessionIds: ReadonlySet<string>): void {
let changed = false;
for (const id of this.bySession.keys()) {
if (!validSessionIds.has(id)) {
this.bySession.delete(id);
changed = true;
}
}
if (!changed) return;
this.version += 1;
for (const listener of this.listeners) listener();
}
}
export const sessionPresentationStore = new SessionPresentationStore();
export function publishSessionDynamicTitle(sessionId: string, title: string | null): void {
sessionPresentationStore.setPresentation(sessionId, { dynamicTitle: title });
}
export function publishSessionCodingCliProvider(
sessionId: string,
providerId: CodingCliProviderId | null,
): void {
sessionPresentationStore.setPresentation(sessionId, { codingCliProviderId: providerId });
}
type SessionWithPresentation = {
id: string;
dynamicTitle?: string;
codingCliProviderId?: CodingCliProviderId;
};
/**
* Overlay live presentation chrome onto a session snapshot.
* Used by TopTabs, focus sidebar, and pane chrome so title/provider updates
* stay live without structural setSessions thrash.
*/
export function applySessionPresentation<T extends SessionWithPresentation>(session: T): T {
const presentation = sessionPresentationStore.getPresentation(session.id);
if (!presentation) return session;
const nextTitle = presentation.dynamicTitle === undefined
? session.dynamicTitle
: (presentation.dynamicTitle ?? undefined);
const nextProvider = presentation.codingCliProviderId === undefined
? session.codingCliProviderId
: (presentation.codingCliProviderId ?? undefined);
if (
nextTitle === session.dynamicTitle
&& nextProvider === session.codingCliProviderId
) {
return session;
}
return {
...session,
dynamicTitle: nextTitle,
codingCliProviderId: nextProvider,
};
}
/** Subscribe to live title/provider chrome version for multi-session consumers. */
export function useSessionPresentationVersion(): number {
return useSyncExternalStore(
sessionPresentationStore.subscribe,
sessionPresentationStore.getVersion,
sessionPresentationStore.getVersion,
);
}
/**
* Per-session presentation snapshot. Other sessions' title updates notify the
* store but do not re-render this consumer when its own snapshot is unchanged.
*/
export function useSessionPresentationSnapshot(sessionId: string): string {
return useSyncExternalStore(
sessionPresentationStore.subscribe,
() => sessionPresentationStore.getSessionSnapshot(sessionId),
() => sessionPresentationStore.getSessionSnapshot(sessionId),
);
}
/** Session snapshot with live presentation overlay applied (single session). */
export function usePresentedSession<T extends SessionWithPresentation>(session: T): T {
useSessionPresentationSnapshot(session.id);
return applySessionPresentation(session);
}

View File

@@ -0,0 +1,211 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
DEFAULT_RESTORE_TERMINAL_CWD,
DEFAULT_RESTORE_PREVIOUS_SESSION,
resolveRestoreTerminalCwdSetting,
resolveRestorePreviousSessionSetting,
} from "./sessionRestoreSettings.ts";
test("restore previous session setting defaults on", () => {
assert.equal(DEFAULT_RESTORE_PREVIOUS_SESSION, true);
assert.equal(resolveRestorePreviousSessionSetting(null), true);
});
test("restore previous session setting preserves explicit stored values", () => {
assert.equal(resolveRestorePreviousSessionSetting(true), true);
assert.equal(resolveRestorePreviousSessionSetting(false), false);
});
test("restore terminal cwd setting defaults off", () => {
assert.equal(DEFAULT_RESTORE_TERMINAL_CWD, false);
assert.equal(resolveRestoreTerminalCwdSetting(null), false);
});
test("restore terminal cwd setting preserves explicit stored values", () => {
assert.equal(resolveRestoreTerminalCwdSetting(true), true);
assert.equal(resolveRestoreTerminalCwdSetting(false), false);
});
test("restore previous session setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(storageSyncSource, /setRestorePreviousSessionState/);
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(ipcSyncSource, /setRestorePreviousSessionState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
});
test("disabling restore previous session clears the stored restore snapshot", () => {
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const importIndex = settingsSource.indexOf("sessionRestoreStorage");
const setterIndex = settingsSource.indexOf("const setRestorePreviousSession = useCallback");
const clearIndex = settingsSource.indexOf("sessionRestoreStorage.clear()", setterIndex);
const writeIndex = settingsSource.indexOf("localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION", setterIndex);
assert.notEqual(importIndex, -1);
assert.notEqual(setterIndex, -1);
assert.notEqual(clearIndex, -1);
assert.notEqual(writeIndex, -1);
assert.ok(
writeIndex < clearIndex,
"the setting should be persisted before clearing the restore snapshot",
);
});
test("session restore persistence re-arms when restore previous session is enabled", () => {
const source = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const adapterEventImportIndex = source.indexOf("LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
const revisionStateIndex = source.indexOf("restorePreviousSessionRevision");
const keyGuardIndex = source.indexOf("key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION");
const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload"));
assert.notEqual(adapterEventImportIndex, -1);
assert.notEqual(revisionStateIndex, -1);
assert.notEqual(keyGuardIndex, -1);
assert.notEqual(listenerIndex, -1);
assert.notEqual(effectDependencyIndex, -1);
});
test("session restore persistence can be disabled for non-main windows", () => {
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8");
const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8");
assert.match(hookSource, /persistSessionRestore\?: boolean/);
assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/);
assert.match(hookSource, /payload: persistSessionRestore \? sessionRestoreStorage\.read\(\) : null/);
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
// App no longer calls useSessionState; SessionPublisher owns the hook and
// App passes the peer-window flag down to it.
assert.match(appSource, /<SessionPublisher persistSessionRestore=\{!isPeerSessionWindow\}>/);
assert.match(
readFileSync(new URL("../app/publishers/SessionPublisher.tsx", import.meta.url), "utf8"),
/useSessionState\(\{ persistSessionRestore \}\)/,
);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/);
assert.match(mainWindowSource, /registerAsMainWindow = true/);
assert.match(mainWindowSource, /if \(registerAsMainWindow\)/);
assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/);
assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/);
});
test("session peer windows do not run main-window startup effects", () => {
const appSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
const appRootSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
const appLockGateSource = readFileSync(new URL("../../components/AppLockGate.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const systemEffectsSource = readFileSync(new URL("./systemSettingsEffects.ts", import.meta.url), "utf8");
const trayFocusIndex = appSource.indexOf("onTrayFocusSession");
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appLockGateSource, /settingsOptions\?: Parameters<typeof useSettingsState>\[0\]/);
assert.match(appLockGateSource, /deps\.useSettingsState\(settingsOptions\)/);
assert.match(indexSource, /const isPeerSessionWindow = window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(indexSource, /const settingsOptions = isPeerSessionWindow\s*\?\s*\{ enableSettingsSync: false, enableSystemEffects: false \}/);
assert.match(indexSource, /<AppLockGate settingsOptions=\{settingsOptions\}>/);
// AppLockGate owns useSettingsState; App forwards the gate's instance into
// SettingsPublisher so the runtime slot/context still publish it.
assert.match(appRootSource, /<SettingsPublisher settings=\{settings\}>/);
assert.doesNotMatch(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
assert.match(appSource, /useAutoSync\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(autoSyncSource, /enabled\?: boolean/);
assert.match(autoSyncSource, /const enabled = config\.enabled !== false/);
assert.match(autoSyncSource, /if \(!enabled\) return;/);
assert.match(updateCheckSource, /enabled\?: boolean/);
assert.match(updateCheckSource, /const enabled = options\?\.enabled !== false/);
assert.match(updateCheckSource, /if \(!enabled\) return;/);
assert.match(settingsStateSource, /enableSystemEffects\?: boolean/);
assert.match(settingsStateSource, /enableSettingsSync\?: boolean/);
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
assert.match(storageSyncSource, /enabled\?: boolean/);
assert.match(storageSyncSource, /if \(!enabled\) return;/);
assert.match(systemEffectsSource, /enabled\?: boolean/);
assert.match(systemEffectsSource, /if \(!enabled\) return;/);
assert.ok(
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayFocusIndex) !== -1,
"peer session windows should not register tray focus/toggle listeners",
);
assert.ok(
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayPanelJumpIndex) !== -1,
"peer session windows should not register tray panel listeners",
);
assert.match(startupEffectsSource, /enabled = true/);
assert.match(startupEffectsSource, /if \(!enabled\) return;/);
assert.match(startupEffectsSource, /export function shouldQueueKeyboardInteractiveRequest/);
assert.match(startupEffectsSource, /request\.scope !== "terminal"/);
assert.match(startupEffectsSource, /shouldQueueKeyboardInteractiveRequest\(request, sessionsRef\.current\)/);
assert.doesNotMatch(
startupEffectsSource,
/if \(!enabled\) return;\s*const bridge = netcattyBridge\.get\(\);\s*if \(!bridge\?\.onCheckDirtyEditors\) return;/,
"dirty editor quit guard must remain registered in peer session windows",
);
});
test("restore-only settings do not bump the cloud sync settings version", () => {
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
assert.notEqual(settingsVersionIndex, -1);
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd|startupLanding/);
});
test("restore previous session re-arms after cross-window settings ipc sync", () => {
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(settingsIpcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(hookSource, /netcattyBridge/);
assert.match(hookSource, /onSettingsChanged/);
assert.match(hookSource, /handleRestorePreviousSessionChanged\(payload\?\.key\)/);
assert.match(hookSource, /key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(hookSource, /setRestorePreviousSessionRevision\(\(revision\) => revision \+ 1\)/);
});
test("restore terminal cwd setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(storageSyncSource, /setRestoreTerminalCwdState/);
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
});

Some files were not shown because too many files have changed in this diff Show More