[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
34
application/state/activeChromeThemeSync.test.ts
Normal file
34
application/state/activeChromeThemeSync.test.ts
Normal 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/);
|
||||
});
|
||||
80
application/state/activeChromeThemeSync.ts
Normal file
80
application/state/activeChromeThemeSync.ts
Normal 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);
|
||||
85
application/state/activeTabStore.test.ts
Normal file
85
application/state/activeTabStore.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
114
application/state/activeTabStore.ts
Normal file
114
application/state/activeTabStore.ts
Normal 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);
|
||||
};
|
||||
478
application/state/aiDraftState.test.ts
Normal file
478
application/state/aiDraftState.test.ts
Normal 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);
|
||||
});
|
||||
363
application/state/aiDraftState.ts
Normal file
363
application/state/aiDraftState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
application/state/aiPanelDiagnostics.ts
Normal file
85
application/state/aiPanelDiagnostics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
application/state/aiProviderCleanup.ts
Normal file
39
application/state/aiProviderCleanup.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
182
application/state/aiScopeCleanup.test.ts
Normal file
182
application/state/aiScopeCleanup.test.ts
Normal 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");
|
||||
});
|
||||
124
application/state/aiScopeCleanup.ts
Normal file
124
application/state/aiScopeCleanup.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
68
application/state/aiSessionsStore.test.ts
Normal file
68
application/state/aiSessionsStore.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
90
application/state/aiSessionsStore.ts
Normal file
90
application/state/aiSessionsStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
20
application/state/aiStateEvents.ts
Normal file
20
application/state/aiStateEvents.ts
Normal 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 } }));
|
||||
}
|
||||
513
application/state/aiStateSnapshots.test.ts
Normal file
513
application/state/aiStateSnapshots.test.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
513
application/state/aiStateSnapshots.ts
Normal file
513
application/state/aiStateSnapshots.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
99
application/state/appRuntimeBridge.test.ts
Normal file
99
application/state/appRuntimeBridge.test.ts
Normal 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);
|
||||
});
|
||||
181
application/state/appRuntimeBridge.ts
Normal file
181
application/state/appRuntimeBridge.ts
Normal 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;
|
||||
}
|
||||
31
application/state/appearanceChromeStore.test.ts
Normal file
31
application/state/appearanceChromeStore.test.ts
Normal 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();
|
||||
});
|
||||
68
application/state/appearanceChromeStore.ts
Normal file
68
application/state/appearanceChromeStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
368
application/state/appearanceSync.test.ts
Normal file
368
application/state/appearanceSync.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
202
application/state/appearanceSync.ts
Normal file
202
application/state/appearanceSync.ts
Normal 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 };
|
||||
}
|
||||
37
application/state/autoSyncHashDecision.test.ts
Normal file
37
application/state/autoSyncHashDecision.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
16
application/state/autoSyncHashDecision.ts
Normal file
16
application/state/autoSyncHashDecision.ts
Normal 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';
|
||||
}
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal 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);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal 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;
|
||||
}
|
||||
231
application/state/codingCliSessionSignalController.test.ts
Normal file
231
application/state/codingCliSessionSignalController.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
236
application/state/codingCliSessionSignalController.ts
Normal file
236
application/state/codingCliSessionSignalController.ts
Normal 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;
|
||||
}
|
||||
36
application/state/commandBlocklistSettings.test.ts
Normal file
36
application/state/commandBlocklistSettings.test.ts
Normal 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);
|
||||
});
|
||||
26
application/state/commandBlocklistSettings.ts
Normal file
26
application/state/commandBlocklistSettings.ts
Normal 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;
|
||||
}
|
||||
126
application/state/connectionLogsStore.test.ts
Normal file
126
application/state/connectionLogsStore.test.ts
Normal 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();
|
||||
});
|
||||
135
application/state/connectionLogsStore.ts
Normal file
135
application/state/connectionLogsStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
151
application/state/copyWorkspace.test.ts
Normal file
151
application/state/copyWorkspace.test.ts
Normal 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']);
|
||||
});
|
||||
204
application/state/customAccentSync.test.ts
Normal file
204
application/state/customAccentSync.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
93
application/state/customAccentSync.ts
Normal file
93
application/state/customAccentSync.ts
Normal 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' };
|
||||
}
|
||||
172
application/state/customThemeStore.ts
Normal file
172
application/state/customThemeStore.ts
Normal 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 };
|
||||
};
|
||||
960
application/state/defaultKeyPassphrases.test.ts
Normal file
960
application/state/defaultKeyPassphrases.test.ts
Normal 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;
|
||||
});
|
||||
69
application/state/editorSftpBridge.ts
Normal file
69
application/state/editorSftpBridge.ts
Normal 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");
|
||||
};
|
||||
226
application/state/editorTabSave.test.ts
Normal file
226
application/state/editorTabSave.test.ts
Normal 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);
|
||||
});
|
||||
82
application/state/editorTabSave.ts
Normal file
82
application/state/editorTabSave.ts
Normal 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;
|
||||
370
application/state/editorTabStore.test.ts
Normal file
370
application/state/editorTabStore.test.ts
Normal 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);
|
||||
});
|
||||
395
application/state/editorTabStore.ts
Normal file
395
application/state/editorTabStore.ts
Normal 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),
|
||||
);
|
||||
9
application/state/fontStore.test.ts
Normal file
9
application/state/fontStore.test.ts
Normal 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\(\);/);
|
||||
});
|
||||
185
application/state/fontStore.ts
Normal file
185
application/state/fontStore.ts
Normal 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();
|
||||
};
|
||||
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
52
application/state/hostTreeInlineGroupEditStore.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
41
application/state/hostTreeInlineHostEditStore.ts
Normal file
41
application/state/hostTreeInlineHostEditStore.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
102
application/state/inheritedCwd.test.ts
Normal file
102
application/state/inheritedCwd.test.ts
Normal 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");
|
||||
});
|
||||
87
application/state/inheritedCwd.ts
Normal file
87
application/state/inheritedCwd.ts
Normal 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;
|
||||
}
|
||||
24
application/state/logViewState.ts
Normal file
24
application/state/logViewState.ts
Normal 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);
|
||||
73
application/state/networkDeviceModeSuggestion.ts
Normal file
73
application/state/networkDeviceModeSuggestion.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
114
application/state/notesStore.test.ts
Normal file
114
application/state/notesStore.test.ts
Normal 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);
|
||||
});
|
||||
143
application/state/notesStore.ts
Normal file
143
application/state/notesStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
97
application/state/oscDesktopNotifications.test.ts
Normal file
97
application/state/oscDesktopNotifications.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
60
application/state/oscDesktopNotifications.ts
Normal file
60
application/state/oscDesktopNotifications.ts
Normal 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;
|
||||
}
|
||||
91
application/state/pluginContributionContexts.test.ts
Normal file
91
application/state/pluginContributionContexts.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
74
application/state/pluginContributionContexts.ts
Normal file
74
application/state/pluginContributionContexts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
application/state/pluginContributionEnvironment.test.ts
Normal file
18
application/state/pluginContributionEnvironment.test.ts
Normal 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%',
|
||||
});
|
||||
});
|
||||
19
application/state/pluginContributionEnvironment.ts
Normal file
19
application/state/pluginContributionEnvironment.ts
Normal 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]] : [];
|
||||
}));
|
||||
}
|
||||
89
application/state/pluginExtensionBridge.test.ts
Normal file
89
application/state/pluginExtensionBridge.test.ts
Normal 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();
|
||||
});
|
||||
82
application/state/pluginExtensionBridge.ts
Normal file
82
application/state/pluginExtensionBridge.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
111
application/state/pluginImporterTransaction.test.ts
Normal file
111
application/state/pluginImporterTransaction.test.ts
Normal 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']);
|
||||
});
|
||||
108
application/state/pluginImporterTransaction.ts
Normal file
108
application/state/pluginImporterTransaction.ts
Normal 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);
|
||||
}
|
||||
61
application/state/pluginKeybindings.test.ts
Normal file
61
application/state/pluginKeybindings.test.ts
Normal 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);
|
||||
});
|
||||
136
application/state/pluginKeybindings.ts
Normal file
136
application/state/pluginKeybindings.ts
Normal 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('+');
|
||||
}
|
||||
29
application/state/pluginRuntimeStatusCache.test.ts
Normal file
29
application/state/pluginRuntimeStatusCache.test.ts
Normal 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);
|
||||
});
|
||||
39
application/state/pluginRuntimeStatusCache.ts
Normal file
39
application/state/pluginRuntimeStatusCache.ts
Normal 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();
|
||||
}
|
||||
432
application/state/pluginTerminalProviderRegistry.test.ts
Normal file
432
application/state/pluginTerminalProviderRegistry.test.ts
Normal 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);
|
||||
});
|
||||
319
application/state/pluginTerminalProviderRegistry.ts
Normal file
319
application/state/pluginTerminalProviderRegistry.ts
Normal 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';
|
||||
38
application/state/pluginTerminalRuntimeLifecycle.test.ts
Normal file
38
application/state/pluginTerminalRuntimeLifecycle.test.ts
Normal 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'],
|
||||
]);
|
||||
});
|
||||
42
application/state/pluginTerminalRuntimeLifecycle.ts
Normal file
42
application/state/pluginTerminalRuntimeLifecycle.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
213
application/state/pluginViewLifecycle.test.ts
Normal file
213
application/state/pluginViewLifecycle.test.ts
Normal 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);
|
||||
});
|
||||
321
application/state/pluginViewLifecycle.ts
Normal file
321
application/state/pluginViewLifecycle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
application/state/pluginViewScopes.test.ts
Normal file
47
application/state/pluginViewScopes.test.ts
Normal 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);
|
||||
});
|
||||
31
application/state/pluginViewScopes.ts
Normal file
31
application/state/pluginViewScopes.ts
Normal 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;
|
||||
}
|
||||
120
application/state/pluginViewTabStore.test.ts
Normal file
120
application/state/pluginViewTabStore.test.ts
Normal 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');
|
||||
});
|
||||
147
application/state/pluginViewTabStore.ts
Normal file
147
application/state/pluginViewTabStore.ts
Normal 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);
|
||||
}
|
||||
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal file
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal 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" });
|
||||
});
|
||||
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal file
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal 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' };
|
||||
}
|
||||
67
application/state/resolveCloseIntent.test.ts
Normal file
67
application/state/resolveCloseIntent.test.ts
Normal 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" });
|
||||
});
|
||||
34
application/state/resolveCloseIntent.ts
Normal file
34
application/state/resolveCloseIntent.ts
Normal 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 };
|
||||
}
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal 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" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal 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 };
|
||||
}
|
||||
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
42
application/state/resolveSnippetsShortcutIntent.ts
Normal 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' };
|
||||
}
|
||||
122
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
122
application/state/resolveTerminalSessionExitIntent.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
51
application/state/resolveTerminalSessionExitIntent.ts
Normal file
51
application/state/resolveTerminalSessionExitIntent.ts
Normal 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;
|
||||
}
|
||||
248
application/state/scriptAutomationCoordinator.test.ts
Normal file
248
application/state/scriptAutomationCoordinator.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
288
application/state/scriptAutomationCoordinator.ts
Normal file
288
application/state/scriptAutomationCoordinator.ts
Normal 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 };
|
||||
}
|
||||
30
application/state/scriptRecordingStore.ts
Normal file
30
application/state/scriptRecordingStore.ts
Normal 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();
|
||||
}
|
||||
35
application/state/scriptRunsStore.test.ts
Normal file
35
application/state/scriptRunsStore.test.ts
Normal 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();
|
||||
});
|
||||
43
application/state/scriptRunsStore.ts
Normal file
43
application/state/scriptRunsStore.ts
Normal 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);
|
||||
}
|
||||
46
application/state/sessionActivity.ts
Normal file
46
application/state/sessionActivity.ts
Normal 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;
|
||||
};
|
||||
112
application/state/sessionActivityStore.ts
Normal file
112
application/state/sessionActivityStore.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
};
|
||||
86
application/state/sessionCapabilitiesStore.ts
Normal file
86
application/state/sessionCapabilitiesStore.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
17
application/state/sessionFactories.localHostId.test.ts
Normal file
17
application/state/sessionFactories.localHostId.test.ts
Normal 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");
|
||||
});
|
||||
151
application/state/sessionFactories.test.ts
Normal file
151
application/state/sessionFactories.test.ts
Normal 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);
|
||||
});
|
||||
128
application/state/sessionFactories.ts
Normal file
128
application/state/sessionFactories.ts
Normal 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,
|
||||
});
|
||||
128
application/state/sessionPresentationStore.test.ts
Normal file
128
application/state/sessionPresentationStore.test.ts
Normal 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');
|
||||
});
|
||||
163
application/state/sessionPresentationStore.ts
Normal file
163
application/state/sessionPresentationStore.ts
Normal 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);
|
||||
}
|
||||
211
application/state/sessionRestoreSettings.test.ts
Normal file
211
application/state/sessionRestoreSettings.test.ts
Normal 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
Reference in New Issue
Block a user