Compare commits

..
Author SHA1 Message Date
陈大猫 d014532dc3 fix(terminal): chunk PTY OSC7 install under canonical line limit (#4052) 2026-10-29 12:29:23 +08:00
陈大猫 5df98841f0 fix(terminal): PTY OSC7 install when exec cannot verify shell (#4052)
On confirmed Configure directory tracking, route exec exit 4 identity failures through a dedicated inline foreground PTY transport, separate from #1942 /tmp staging.

Require empty pending input, probe the interactive bash/zsh shell, reuse the existing OSC7 setup script with FORCE_SHELL, and await a DONE acknowledgement before reporting success. Keep OTHER_USER staging and silent setup behavior unchanged.
2026-10-29 12:20:26 +08:00
539 changed files with 6192 additions and 25301 deletions

No files matched your search

-3
View File
@@ -170,6 +170,3 @@ build_with_vs2022.bat
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
-5
View File
@@ -127,11 +127,6 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path.
-3
View File
@@ -50,9 +50,6 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
*/
function App() {
useEffect(() => {
performance.mark('netcatty:renderer:app-mounted');
}, []);
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
@@ -1,84 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleCreateLocalTerminalImpl } from './AppHandlers';
type CreatedLocal = {
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
shellType?: string;
};
function createCtx(overrides?: {
localShell?: string;
localShellArgs?: string[];
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
}) {
const created: CreatedLocal[] = [];
const logs: Array<{ hostLabel: string }> = [];
const localShell = overrides?.localShell ?? '';
const localShellArgs = overrides?.localShellArgs ?? [];
const discoveredShells = overrides?.discoveredShells ?? [];
const getCtx = () => ({
addConnectionLog: (entry: { hostLabel: string }) => {
logs.push(entry);
},
classifyLocalShellType: () => 'unknown',
createLocalTerminal: (options: CreatedLocal) => {
created.push(options);
return 'session-1';
},
discoveredShells,
resolveShellSetting: (
value: string,
shells: Array<{ id: string; command: string; args?: string[] }>,
customArgs?: string[],
) => {
if (!value) return null;
const match = shells.find((shell) => shell.id === value);
if (match) {
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
}
return { command: value, args: customArgs?.length ? customArgs : undefined };
},
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
terminalSettings: { localShell, localShellArgs },
});
return { getCtx, created, logs };
}
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
handleCreateLocalTerminalImpl(getCtx as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
// passes the click event as the first argument.
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal still honors an explicit shell object', () => {
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
handleCreateLocalTerminalImpl(getCtx as never, {
command: String.raw`D:\portable\zsh\zsh.exe`,
args: ['-l'],
name: 'Portable Zsh',
icon: 'zsh',
});
assert.equal(created.length, 1);
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
assert.deepEqual(created[0].shellArgs, ['-l']);
assert.equal(created[0].shellName, 'Portable Zsh');
assert.equal(created[0].shellIcon, 'zsh');
});
+8 -14
View File
@@ -16,7 +16,6 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
@@ -265,7 +264,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
@@ -1138,18 +1137,11 @@ export function handleCreateLocalTerminalImpl(
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
// Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
// React then passes the MouseEvent as the first arg; treat only real shell
// objects (string command) as explicit so Settings localShell still applies (#4085).
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
const resolved = explicitShell
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !explicitShell
? discoveredShells.find(s => s.id === terminalSettings.localShell)
: undefined;
const shellName = explicitShell?.name ?? matchedShell?.name;
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
@@ -1279,14 +1271,16 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
}
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
{
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
handleConnectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}
}
+2 -11
View File
@@ -139,21 +139,12 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
// dialogs bag per keystroke re-rendered the whole shell.
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
});
test('QuickSwitcherOverlay owns the quick switcher query', () => {
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
assert.match(overlay, /useState\(''\)/);
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
assert.doesNotMatch(store, /quickSearch/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
+8 -27
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
import { registerAppHandlers } from './appHandlersBridge';
import { publishAppLocalUi } from './appLocalUiStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { useAutoSync } from '../state/useAutoSync';
@@ -19,8 +19,6 @@ import {
} from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -61,7 +59,6 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -136,6 +133,7 @@ export function AppSideEffects() {
| { mode: 'create' }
| null
>(null);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
@@ -1158,8 +1156,6 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
@@ -1528,19 +1524,6 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1585,8 +1568,6 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -1963,7 +1944,7 @@ export function AppSideEffects() {
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
@@ -2031,11 +2012,8 @@ export function AppSideEffects() {
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
// Handlers are registered behind stable per-key wrappers so session/host
// churn does not re-notify every Host (see stabilizeAppHandlers).
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
useLayoutEffect(() => {
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
registerAppHandlers({
// Vault glue
handleAddKnownHost,
handleDeleteHost,
@@ -2095,13 +2073,15 @@ export function AppSideEffects() {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
handleCancelDeleteHost,
handleConfirmDeleteHost,
}));
});
publishAppLocalUi({
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
@@ -2167,6 +2147,7 @@ export function AppSideEffects() {
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
+54 -51
View File
@@ -46,7 +46,6 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
import {
appViewDomainsEqual,
mergeAppViewDomains,
@@ -54,6 +53,9 @@ import {
} from './appViewDomains';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
@@ -61,9 +63,6 @@ const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
);
// Module-level so TopTabs' memo comparator sees a stable callback.
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -243,10 +242,12 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
@@ -259,6 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
@@ -275,7 +277,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -504,16 +506,6 @@ function AppViewInner({ domains }: AppViewProps) {
await popOutEditorTab(id, host?.label);
}, [hostById]);
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
// skip re-rendering when unrelated AppView state changes.
const requestCloseEditorTabFromView = useCallback(
(id: string) => handleRequestCloseEditorTabRef.current(id),
[handleRequestCloseEditorTabRef],
);
const popOutEditorTabFromView = useCallback((id: string) => {
void handlePopOutEditorTab(id);
}, [handlePopOutEditorTab]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -591,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={focusEditorWindowTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -855,8 +847,8 @@ function AppViewInner({ domains }: AppViewProps) {
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={requestCloseEditorTabFromView}
onPopOut={popOutEditorTabFromView}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
/>
</Suspense>
</LazyLoadBoundary>
@@ -945,39 +937,50 @@ function AppViewInner({ domains }: AppViewProps) {
)}
{isQuickSwitcherOpen && (
<QuickSwitcherOverlay
hosts={hosts}
isOpen={isQuickSwitcherOpen}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
</Suspense>
</LazyLoadBoundary>
)}
<RenameDraftDialog
-48
View File
@@ -1,48 +0,0 @@
import React, { Suspense, lazy, useMemo, useState } from 'react';
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { getHostSearchMatch } from '../../lib/searchMatcher';
import type { Host } from '../../types';
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
/** Hosts matching the quick switcher query, best match first. */
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
const term = query.trim();
if (!term) return hosts;
return hosts
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}
export type QuickSwitcherOverlayProps =
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
/**
* Owns the quick switcher query. It used to live in AppSideEffects and travel
* through the app-local UI store, so every keystroke re-rendered the whole
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
* The overlay is only mounted while the switcher is open, so closing it resets
* the query just like the old `setQuickSearch('')` calls did.
*/
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
const [query, setQuery] = useState('');
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
return (
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
<Suspense fallback={null}>
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
</Suspense>
</LazyLoadBoundary>
);
}
-45
View File
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppHandlers,
registerAppHandlers,
stabilizeAppHandlers,
subscribeAppHandlers,
type StableAppHandlerCache,
} from './appHandlersBridge';
test('stabilized handlers keep their identity and call the latest implementation', () => {
const cache: StableAppHandlerCache = new Map();
const ref = { current: 1 };
const calls: string[] = [];
const first = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
toggleSidePanelRef: ref,
});
const second = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
toggleSidePanelRef: ref,
});
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
(first.closeTabsBatch as (id: string) => void)('a');
assert.deepEqual(calls, ['v2:a']);
});
test('the bridge does not notify Hosts when only handler implementations change', () => {
const cache: StableAppHandlerCache = new Map();
let notified = 0;
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
try {
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
const afterFirst = notified;
// A session status change recreated the callback (it closes over hosts).
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
assert.equal(notified, afterFirst);
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
} finally {
unsubscribe();
registerAppHandlers(null);
}
});
-31
View File
@@ -51,37 +51,6 @@ class AppHandlersBridge {
const bridge = new AppHandlersBridge();
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
/**
* Give every function-valued handler a per-key wrapper with a stable identity
* that always calls the latest implementation; non-functions (refs) pass
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
* identities changed on every session status change and host update (connect
* bursts, last-connected times), and the bridge re-notified every Host island,
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
* handlers are actions invoked from events, never read during render.
*/
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
const stableHandlers: AppHandlers = {};
for (const [key, value] of Object.entries(handlers)) {
if (typeof value !== 'function') {
stableHandlers[key] = value;
continue;
}
let entry = cache.get(key);
if (!entry) {
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
cache.set(key, created);
entry = created;
}
entry.latest = value;
stableHandlers[key] = entry.stable;
}
return stableHandlers;
}
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
+3
View File
@@ -24,6 +24,7 @@ export type AppLocalUiSnapshot = {
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
@@ -42,6 +43,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
@@ -74,6 +76,7 @@ class AppLocalUiStore {
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
+29 -3
View File
@@ -1,6 +1,9 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
@@ -8,14 +11,16 @@ import {
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
* switcher owns its query and host results (QuickSwitcherOverlay), so typing
* there no longer republishes this bag.
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
@@ -23,6 +28,22 @@ export function DialogsHost() {
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
@@ -47,11 +68,14 @@ export function DialogsHost() {
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
@@ -64,6 +88,8 @@ export function DialogsHost() {
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
@@ -14,22 +14,7 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
});
// Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
return vm.runInNewContext(`${code}; ${name}`, {
React,
useState: React.useState,
useEffect: React.useEffect,
useLayoutEffect: React.useLayoutEffect,
useMemo: React.useMemo,
useCallback: React.useCallback,
useRef: React.useRef,
useReducer: React.useReducer,
useContext: React.useContext,
memo: React.memo,
createElement: React.createElement,
Fragment: React.Fragment,
...bindings,
}) as React.ComponentType;
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
}
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
@@ -47,7 +32,6 @@ test('monitor controls update their runtime without rendering unrelated app sibl
};
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', {
performance: { mark: () => {} },
useMonitorRuntime,
MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// Guards for what the startup bundle (index.html entry) may pull in. Each of
// these used to land in the entry chunk that every window parses at launch.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (path: string) => readFileSync(join(root, path), 'utf8');
const staticImports = (source: string) =>
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
test('only the English dictionary is bundled with the startup entry', () => {
const imports = staticImports(read('application/i18n/messages.ts'));
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
});
test('startup modules do not statically import the AI runtime or SDK providers', () => {
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
for (const path of [
'application/state/aiStateSnapshots.ts',
'application/state/usePluginAIContextHost.ts',
'application/state/usePluginAgentHost.ts',
]) {
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
}
});
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
// The entry imports the helper for its dynamic imports; if it is grouped
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
});
test('the QR decoder is loaded on demand', () => {
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
});
+13 -20
View File
@@ -293,10 +293,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
terminalSettings,
});
// Sync tray menu data + handle tray actions. Hosts are only re-sent when the
// host list changed: session status updates (connect bursts) used to resend
// and re-serialize every host and rebuild the macOS Dock menu each time.
const lastTrayHostsRef = useRef<unknown>(null);
// Sync tray menu data + handle tray actions
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
@@ -319,21 +316,17 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsChanged = lastTrayHostsRef.current !== hosts;
lastTrayHostsRef.current = hosts;
const hostsForSystemMenu = hostsChanged
? hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}))
: undefined;
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
@@ -341,7 +334,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
...rule,
canStop: hasRuntimeTunnel(rule.id),
})),
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
hosts: hostsForSystemMenu,
});
}, 250);
@@ -21,20 +21,13 @@ Object.defineProperty(globalThis, 'localStorage', {
});
const {
applyLegacySyncPayload,
convergentConflictAddressKey,
createConvergentSyncStateFromPayload,
dotKey,
mergeConvergentSyncStates,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
stripConvergentSyncEnvelope,
} = await import('../domain/convergentSync/index.ts');
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
const {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
} = await import('./convergentSyncMigration.ts');
function payload(): SyncPayload {
@@ -51,42 +44,6 @@ test.beforeEach(() => {
localStorageValues.clear();
});
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const state = mergeConvergentSyncStates(
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
);
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
}), state);
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
const conflict = plan.preview.conflicts[0];
const addressKey = convergentConflictAddressKey(conflict.address);
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
const resolved = resolvePreparedConvergentMigrationConflict(
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
);
assert.equal(resolved.plan.preview.canInitialize, true);
assert.equal(resolved.plan.preview.conflictCount, 0);
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
assert.ok(resolved.plan.state?.vector.resolver);
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
assert.equal(prepared.plan.preview.conflictCount, 1);
assert.equal(prepared.plan.payload, null);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, 'missing', dotKey(selected.dot), manager,
), /conflict no longer exists/);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, addressKey, 'missing', manager,
), /candidate no longer exists/);
});
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
const remotePayload: SyncPayload = {
...payload(),
-22
View File
@@ -6,11 +6,8 @@ import type {
} from '../domain/sync';
import {
cloudSyncPayloadsEqual,
convergentConflictAddressKey,
materializeSyncPayloadFromConvergentState,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
resolveConvergentFieldConflict,
stripConvergentSyncEnvelope,
validateConvergentSyncPayload,
type ConvergentMigrationPlan,
@@ -28,25 +25,6 @@ export interface PreparedConvergentMigration {
localSnapshot: SyncPayload;
}
export function resolvePreparedConvergentMigrationConflict(
prepared: PreparedConvergentMigration,
addressKey: string,
candidateDot: string,
manager: CloudSyncManager = getCloudSyncManager(),
now = Date.now(),
): PreparedConvergentMigration {
const conflict = prepared.plan.preview.conflicts.find(
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
);
if (!prepared.plan.state || !conflict) {
throw new Error('The convergent migration conflict no longer exists');
}
const state = resolveConvergentFieldConflict(
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
);
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
}
interface LegacyProviderBaselineSeed {
provider: CloudProvider;
remoteVersion: number;
+12 -46
View File
@@ -1,13 +1,6 @@
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
import React, { createContext, useContext, useMemo } from 'react';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
type Messages,
} from './messages';
import { MESSAGES_BY_LOCALE } from './messages';
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
@@ -36,59 +29,32 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
type LocaleDictionaries = {
direct?: Messages;
base?: Messages;
fallback?: Messages;
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
if (direct) return direct;
const base = resolvedLocale.split('-')[0];
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
if (baseHit) return baseHit;
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
};
/**
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
* dictionary that finishes loading yields a fresh snapshot.
*/
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
const baseKey = getBaseFallbackLocale(resolvedLocale);
return {
direct: getLoadedLocaleMessages(resolvedLocale),
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
};
};
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
locale,
children,
}) => {
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
// Non-English dictionaries load on demand (index.tsx preloads the saved one
// before the first render); re-render consumers once a switch finishes.
const messagesVersion = useSyncExternalStore(
subscribeLocaleMessages,
getLocaleMessagesVersion,
getLocaleMessagesVersion,
);
useEffect(() => {
void loadLocaleMessages(resolvedLocale).catch(() => {});
}, [resolvedLocale]);
const dictionaries = useMemo(
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
[resolvedLocale, messagesVersion],
);
const value = useMemo<I18nContextValue>(() => {
return {
locale,
resolvedLocale,
t: (key, values) => {
const msg = resolveMessage(dictionaries, key) ?? key;
const msg = resolveMessage(resolvedLocale, key) ?? key;
return interpolate(msg, values);
},
};
}, [dictionaries, locale, resolvedLocale]);
}, [locale, resolvedLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
-5
View File
@@ -99,7 +99,6 @@ export const enAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
// AI Codex
'ai.codex': 'Codex',
@@ -661,10 +660,6 @@ export const enAiMessages: Messages = {
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
'ai.chatHistory.title': 'Chat History',
'ai.chatHistory.messageCap': 'Messages kept per chat',
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
'ai.chatHistory.messageCap.unit': 'messages',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
-39
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.github.install": "Install from GitHub",
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
"settings.plugins.github.repository": "GitHub repository",
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
"settings.plugins.github.confirm": "Install disabled",
"settings.plugins.github.download": "Download and review",
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
"settings.plugins.github.error.details": "Technical details",
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
@@ -1439,7 +1401,6 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.directory.open': 'Current directory: {path}. Change directory',
'terminal.directory.unknown': 'Directory unknown',
'terminal.directory.search': 'Search child directories...',
'terminal.directory.directories': 'Directories',
'terminal.directory.parent': '.. (Parent directory)',
'terminal.directory.loading': 'Reading directories...',
'terminal.directory.empty': 'No child directories',
'terminal.directory.noMatches': 'No matching directories',
'terminal.directory.hidden': 'Show hidden directories',
'terminal.directory.refresh': 'Refresh',
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
'terminal.directory.busy': 'Wait for an empty shell prompt.',
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
@@ -478,7 +460,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
@@ -493,7 +474,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
-5
View File
@@ -90,7 +90,6 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
@@ -661,10 +660,6 @@ export const esAiMessages: Messages = {
'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
'ai.chatHistory.title': 'Historial de chat',
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
'ai.chatHistory.messageCap.unit': 'mensajes',
// AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
-39
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.github.install": "Instalar desde GitHub",
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
"settings.plugins.github.repository": "Repositorio de GitHub",
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
"settings.plugins.github.confirm": "Instalar desactivado",
"settings.plugins.github.download": "Descargar y revisar",
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
"settings.plugins.github.error.details": "Detalles técnicos",
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
@@ -1439,7 +1401,6 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
'terminal.directory.unknown': 'Directorio desconocido',
'terminal.directory.search': 'Buscar subdirectorios...',
'terminal.directory.directories': 'Directorios',
'terminal.directory.parent': '.. (Directorio padre)',
'terminal.directory.loading': 'Leyendo directorios...',
'terminal.directory.empty': 'Sin subdirectorios',
'terminal.directory.noMatches': 'Sin directorios coincidentes',
'terminal.directory.hidden': 'Mostrar directorios ocultos',
'terminal.directory.refresh': 'Actualizar',
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
@@ -478,7 +460,6 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas',
@@ -493,7 +474,6 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
} from '../messages';
test('only English is loaded until another locale is requested', () => {
assert.ok(getLoadedLocaleMessages('en'));
assert.equal(getLoadedLocaleMessages('ru'), undefined);
});
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
assert.equal(getBaseFallbackLocale('es'), 'es');
assert.equal(getBaseFallbackLocale('fr'), undefined);
});
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
const before = getLocaleMessagesVersion();
let notified = 0;
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
unsubscribe();
assert.ok(getLoadedLocaleMessages('zh-TW'));
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
assert.equal(getLocaleMessagesVersion(), before + 2);
await loadLocaleMessages('zh-TW');
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
});
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
import en from './en';
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
assert.ok(keys.length > 30);
for (const { id } of SUPPORTED_UI_LOCALES) {
await loadLocaleMessages(id);
const messages = getLoadedLocaleMessages(id)!;
for (const key of keys) {
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
}
}
});
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
assert.equal(expired.key, 'settings.plugins.github.error.expired');
assert.equal(expired.details, null);
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
const validator = 'manifest version must be a semantic version';
assert.equal(describePluginGitHubInstallError(validator).details, validator);
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
});
-5
View File
@@ -71,7 +71,6 @@ export const ruAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
// AI Codex
'ai.codex': 'Codex',
@@ -600,10 +599,6 @@ export const ruAiMessages: Messages = {
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
'ai.chatHistory.title': 'История чата',
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
'ai.chatHistory.messageCap.unit': 'сообщений',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
-39
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
"settings.plugins.github.install": "Установить из GitHub",
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
"settings.plugins.github.repository": "Репозиторий GitHub",
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
"settings.plugins.github.confirm": "Установить выключенным",
"settings.plugins.github.download": "Загрузить и проверить",
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
"settings.plugins.github.error.details": "Технические подробности",
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
@@ -1304,7 +1266,6 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
'terminal.directory.unknown': 'Каталог неизвестен',
'terminal.directory.search': 'Поиск подкаталогов...',
'terminal.directory.directories': 'Каталоги',
'terminal.directory.parent': '.. (Родительский каталог)',
'terminal.directory.loading': 'Чтение каталогов...',
'terminal.directory.empty': 'Нет подкаталогов',
'terminal.directory.noMatches': 'Нет подходящих каталогов',
'terminal.directory.hidden': 'Показывать скрытые каталоги',
'terminal.directory.refresh': 'Обновить',
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
@@ -496,7 +478,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий',
@@ -511,7 +492,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
-5
View File
@@ -99,7 +99,6 @@ export const zhCNAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
// AI Codex
'ai.codex': 'Codex',
@@ -661,10 +660,6 @@ export const zhCNAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
'ai.chatHistory.title': '聊天历史',
'ai.chatHistory.messageCap': '每个会话保留的消息数',
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
'ai.chatHistory.messageCap.unit': '条消息',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
-39
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.github.install": "从 GitHub 安装",
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
"settings.plugins.github.repository": "GitHub 仓库",
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
"settings.plugins.github.confirm": "安装并保持禁用",
"settings.plugins.github.download": "下载并审查",
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
"settings.plugins.github.error.details": "技术详情",
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
@@ -1354,7 +1316,6 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.directory.open': '当前目录:{path}。更改目录',
'terminal.directory.unknown': '目录未知',
'terminal.directory.search': '搜索当前目录下的文件夹…',
'terminal.directory.directories': '目录',
'terminal.directory.parent': '..(父目录)',
'terminal.directory.loading': '正在读取目录…',
'terminal.directory.empty': '没有子目录',
'terminal.directory.noMatches': '没有匹配的子目录',
'terminal.directory.hidden': '显示隐藏目录',
'terminal.directory.refresh': '刷新',
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
'terminal.directory.busy': '请等待终端回到空命令提示符。',
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
-2
View File
@@ -888,7 +888,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -903,7 +902,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
-5
View File
@@ -90,7 +90,6 @@ export const zhTWAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設',
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
// AI Codex
'ai.codex': 'Codex',
@@ -651,10 +650,6 @@ export const zhTWAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
'ai.chatHistory.title': '聊天歷史',
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
'ai.chatHistory.messageCap.unit': '則訊息',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
-39
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.github.install": "從 GitHub 安裝",
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
"settings.plugins.github.repository": "GitHub 儲存庫",
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
"settings.plugins.github.confirm": "安裝並保持停用",
"settings.plugins.github.download": "下載並審查",
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
"settings.plugins.github.error.details": "技術詳細資料",
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
@@ -1325,7 +1287,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.directory.open': '目前目錄:{path}。變更目錄',
'terminal.directory.unknown': '目錄未知',
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
'terminal.directory.directories': '目錄',
'terminal.directory.parent': '..(父目錄)',
'terminal.directory.loading': '正在讀取目錄…',
'terminal.directory.empty': '沒有子目錄',
'terminal.directory.noMatches': '沒有符合的子目錄',
'terminal.directory.hidden': '顯示隱藏目錄',
'terminal.directory.refresh': '重新整理',
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
-2
View File
@@ -888,7 +888,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -903,7 +902,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
+11 -72
View File
@@ -1,78 +1,17 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding a loader below and an entry in
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
/**
* Lookup order for the "same base language" fallback in I18nProvider (for
* example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
* order of the old static MESSAGES_BY_LOCALE map.
*/
export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
/**
* Only English is bundled with the startup chunk; other locales are loaded on
* demand. All five dictionaries used to be parsed by every window at startup
* (~1.3 MB of the entry chunk) even though one locale is in use.
*/
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru'),
es: () => import('./locales/es'),
'zh-CN': () => import('./locales/zh-CN'),
'zh-TW': () => import('./locales/zh-TW'),
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
es,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
const loadedMessages: Record<string, Messages> = { en };
const pendingLoads = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
let messagesVersion = 0;
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
return loadedMessages[locale];
}
export function getLocaleMessagesVersion(): number {
return messagesVersion;
}
export function subscribeLocaleMessages(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** The same-base fallback locale that I18nProvider consults for `locale`. */
export function getBaseFallbackLocale(locale: string): string | undefined {
const base = locale.split('-')[0];
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
}
function loadOne(locale: string): Promise<void> {
if (loadedMessages[locale]) return Promise.resolve();
const loader = LOCALE_LOADERS[locale];
if (!loader) return Promise.resolve();
let pending = pendingLoads.get(locale);
if (!pending) {
pending = loader()
.then((module) => {
loadedMessages[locale] = module.default;
messagesVersion += 1;
for (const listener of listeners) listener();
})
.finally(() => {
pendingLoads.delete(locale);
});
pendingLoads.set(locale, pending);
}
return pending;
}
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
export function loadLocaleMessages(locale: string): Promise<void> {
const needed = new Set([locale]);
const fallback = getBaseFallbackLocale(locale);
if (fallback) needed.add(fallback);
return Promise.all([...needed].map(loadOne)).then(() => undefined);
}
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -1,21 +0,0 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
@@ -1,285 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
import {
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_MIGRATION_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbClearSessions,
idbDeleteSessionIds,
idbGetAllSessions,
idbGetMeta,
idbPutMeta,
idbPutSession,
idbPutSessions,
isIndexedDbAvailable,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
readActiveSessionMapFromIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
test('module wrapper roundtrips sessions through the object stores', async () => {
const idb = installFakeIndexedDb();
try {
assert.equal(isIndexedDbAvailable(), true);
const db = await openAiSessionsIdb();
assert.equal(db.objectStoreNames.contains('sessions'), true);
assert.equal(db.objectStoreNames.contains('meta'), true);
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
const all = await idbGetAllSessions(db);
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
await idbDeleteSessionIds(db, ['chat-a']);
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
await idbClearSessions(db);
assert.deepEqual(await idbGetAllSessions(db), []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('meta store roundtrips schema and active-map records', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
// Mirror uses its own connection.
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
assert.equal(result.source, 'indexeddb');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
// Legacy key content was not consumed for a migration when IDB has data.
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
let readCount = 0;
const result = await loadOrMigrateAiSessions(() => {
readCount += 1;
return legacyRaw;
});
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
// Now in IndexedDB (one record per session).
assert.deepEqual(
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
['legacy-1', 'legacy-2'],
);
// Meta records written for the migration.
const db = await openAiSessionsIdb();
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
assert.ok(readCount >= 1);
// The legacy raw string itself is only read, never rewritten or removed
// — the callback is the only interface to the legacy store.
assert.equal(readCount >= 1, true);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => null);
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(result.idbFailed, false);
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
assert.equal(result.migrationPending, false);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => '[]');
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
const idb = installFakeIndexedDb({ failOpen: true });
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, true);
assert.deepEqual(result.sessions, legacy);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(isIndexedDbAvailable(), false);
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions, legacy);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
resetAiSessionsIdbForTests();
}
});
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
const first = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(first.source, 'legacy-localstorage');
// The marker was persisted after the migration data became durable.
let db = await openAiSessionsIdb();
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
// The user deletes a chat; the legacy key stays behind by design.
await idbDeleteSessionIds(db, ['legacy-1']);
// Next boot: the marker is present, so the legacy key is never
// re-migrated and the deletion is not resurrected.
const second = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(second.source, 'indexeddb');
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
db = await openAiSessionsIdb();
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
const idb = installFakeIndexedDb();
try {
// First boot with nothing anywhere records the marker.
const first = await loadOrMigrateAiSessions(() => null);
assert.equal(first.source, 'none');
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
// The user deleted every chat, but the stale legacy key is still there.
const second = await loadOrMigrateAiSessions(
() => JSON.stringify([makeSession('resurrected', 1)]),
);
assert.equal(second.source, 'none');
assert.equal(second.authoritativeEmpty, true);
assert.deepEqual(second.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
const idb = installFakeIndexedDb();
try {
const primary = makeSession('primary', 3);
const diskA = makeSession('disk-a', 2);
const diskB = makeSession('disk-b', 1);
const diskIds = [diskA.id, diskB.id];
const db = await openAiSessionsIdb();
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
assert.equal(first.migrationPending, true);
assert.deepEqual(await idbGetAllSessions(db), [primary]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
await idbPutSession(db, updated);
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
assert.deepEqual(partial.sessions, [updated, diskA]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
assert.equal(complete.source, 'indexeddb');
assert.equal(complete.migrationPending, false);
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
await idbClearSessions(db);
const deleted = await loadOrMigrateAiSessions(() => {
assert.fail('completed migration must not retry the stale overflow markers');
}, diskIds);
assert.equal(deleted.authoritativeEmpty, true);
assert.deepEqual(deleted.sessions, []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('already-here', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
assert.equal(result.source, 'indexeddb');
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
-372
View File
@@ -1,372 +0,0 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* IndexedDB persistence for AI chat sessions (issue #4123).
*
* The legacy store kept every session as one JSON blob in localStorage under
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
* budget was exceeded the degradation ladder dropped whole oldest sessions —
* silently destroying capped chats (~1 MB each) on every write.
*
* IndexedDB removes both halves of that failure mode: records are written
* per session (no global re-serialization), and there is no shared byte
* quota to defend, so byte-driven eviction disappears entirely.
*
* Layout:
* - db `netcatty_ai_sessions`
* - object store `sessions` (keyPath `id`): one record per AISession
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
*
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
* one release as a read-only fallback; it is no longer written or pruned.
*/
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
export const AI_SESSIONS_IDB_VERSION = 1;
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
export const AI_SESSIONS_IDB_META_STORE = 'meta';
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
export function isIndexedDbAvailable(): boolean {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
}
let openPromise: Promise<IDBDatabase> | null = null;
function upgradeDatabase(db: IDBDatabase) {
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
}
}
export function openAiSessionsIdb(): Promise<IDBDatabase> {
if (!isIndexedDbAvailable()) {
return Promise.reject(new Error('IndexedDB is not available'));
}
if (!openPromise) {
openPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
request.onupgradeneeded = () => upgradeDatabase(request.result);
request.onsuccess = () => {
const db = request.result;
// Drop the cached handle so a later open() re-establishes it instead
// of handing out a closed connection.
db.onclose = () => { openPromise = null; };
db.onversionchange = () => {
db.close();
openPromise = null;
};
resolve(db);
};
request.onerror = () => {
openPromise = null;
reject(request.error ?? new Error('Failed to open IndexedDB'));
};
request.onblocked = () => {
openPromise = null;
};
});
}
return openPromise;
}
export function resetAiSessionsIdbForTests() {
openPromise = null;
}
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
const database = db ?? await openAiSessionsIdb();
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
const result = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
);
return Array.isArray(result) ? result : [];
}
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
if (sessions.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const session of sessions) store.put(session);
await transactionDone(transaction);
}
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
await idbPutSessions(db, [session]);
}
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const id of ids) store.delete(id);
await transactionDone(transaction);
}
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
await transactionDone(transaction);
}
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
const record = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
);
return record?.value;
}
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
await transactionDone(transaction);
}
// ── Cross-window change notification ──
let changeChannel: BroadcastChannel | null = null;
function getChangeChannel(): BroadcastChannel | null {
if (typeof BroadcastChannel === 'undefined') return null;
try {
if (!changeChannel) {
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
// The channel must never keep the process alive (Node exposes unref).
(changeChannel as unknown as { unref?: () => void }).unref?.();
}
return changeChannel;
} catch {
return null;
}
}
/** Announce a session-store change to OTHER windows (never the sender). */
export function publishAiSessionsIdbChange(): void {
try {
getChangeChannel()?.postMessage(Date.now());
} catch {
// Notification is best-effort; the write itself already succeeded.
}
}
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
const channel = getChangeChannel();
if (!channel) return () => {};
const handler = () => listener();
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}
// ── Boot load + one-release legacy migration ──
export interface AiSessionsBootLoadResult {
sessions: AISession[];
/** Where the returned sessions came from. */
source: 'indexeddb' | 'legacy-localstorage' | 'none';
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
idbFailed: boolean;
/** The store was already initialized before this boot; empty means deleted. */
authoritativeEmpty: boolean;
/** Marked disk-only records are still missing; keep their backup generations. */
migrationPending: boolean;
}
/**
* Boot-time store resolution, shared with tests:
* 1. IndexedDB `getAll()` — the primary store.
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
* legacy key itself stays untouched as a read-only fallback for one
* release).
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
*
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
* marker is persisted in the meta store after the first successful boot
* resolution. Once present, the legacy key is never consulted again —
* otherwise a boot whose migration ran while the legacy key could not be
* removed would re-migrate the stale legacy list and resurrect deleted
* chats. The marker also distinguishes "intentionally empty" (the user
* deleted every chat) from "uninitialized".
*
* Callers decide what to do when both sources are empty (the #4018 backup
* generations remain the last restore step in useAIState).
* The legacy reader may await disk-only recovery before migration completes.
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
*/
export async function loadOrMigrateAiSessions(
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
diskOnlySessionIds: readonly string[] = [],
): Promise<AiSessionsBootLoadResult> {
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
const ids = new Set(sessions.map(session => session.id));
return diskOnlySessionIds.some(id => !ids.has(id));
};
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
const legacy = parseLegacySessions(await readLegacyRaw());
return {
sessions: legacy,
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed,
authoritativeEmpty: false,
migrationPending: hasUnresolvedDiskSessions(legacy),
};
};
if (!isIndexedDbAvailable()) {
return legacyFallback(false);
}
let db: IDBDatabase;
try {
db = await openAiSessionsIdb();
} catch (error) {
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let sessions: AISession[];
try {
sessions = await idbGetAllSessions(db);
} catch (error) {
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let marker: AiSessionsMigrationMarker | undefined;
try {
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
} catch {
marker = undefined;
}
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
if (!marker) {
// First boot on a store that already holds sessions (e.g. migrated by
// a build without the marker): record one-shot completion so the
// legacy key can never re-seed the store.
await writeMigrationMarker(db).catch(() => {});
}
return {
sessions,
source: sessions.length > 0 ? 'indexeddb' : 'none',
idbFailed: false,
authoritativeEmpty: sessions.length === 0,
migrationPending: false,
};
}
// Unfinished migration (empty or partially populated IDB). The legacy key is
// intentionally NOT removed: it stays as a read-only fallback for one
// release.
const legacy = parseLegacySessions(await readLegacyRaw());
// A partial migration may already have primary/chunk records in IDB. Only
// fill missing marked IDs; never replay stale primary data over live IDB.
const existingIds = new Set(sessions.map(session => session.id));
const diskOnlyIds = new Set(diskOnlySessionIds);
const recovered = sessions.length === 0
? legacy
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
const migrationPending = hasUnresolvedDiskSessions(merged);
const result: AiSessionsBootLoadResult = {
sessions: merged,
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed: false,
authoritativeEmpty: false,
migrationPending,
};
if (merged.length === 0) {
// Nothing to migrate: mark the store initialized so this state is
// distinguishable from "uninitialized" on the next boot.
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
return result;
}
try {
await idbPutSessions(db, recovered);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
// Written AFTER the migration data is durable: a crash before this point
// leaves the marker absent and the next boot safely re-migrates.
if (!migrationPending) await writeMigrationMarker(db);
} catch (error) {
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
return { ...result, idbFailed: true };
}
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
return result;
}
interface AiSessionsMigrationMarker {
migrationCompleted: true;
migrationAt: number;
}
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
await idbPutMeta<AiSessionsMigrationMarker>(
db,
AI_SESSIONS_META_MIGRATION_KEY,
{ migrationCompleted: true, migrationAt: Date.now() },
);
}
function parseLegacySessions(raw: string | null | undefined): AISession[] {
if (raw == null || raw === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
// ── Active-session map mirror (meta store) ──
/**
* Mirror the active-pointer map into the IndexedDB meta store. The
* localStorage key remains written as before (small, quota-safe) and stays
* the read-mostly authority for this release; the mirror makes the pointer
* available to future store work without another migration.
*/
export async function mirrorActiveSessionMapToIdb(
map: Record<string, string | null>,
): Promise<void> {
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
} catch (error) {
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
}
}
/**
* Read the mirrored active-pointer map (boot restore aid when the
* localStorage key is missing).
*/
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
try {
const db = await openAiSessionsIdb();
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
return value && typeof value === 'object' ? value : null;
} catch {
return null;
}
}
+2 -4
View File
@@ -66,12 +66,10 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
});
test('restore preserves all sessions when cloud sync is enabled', () => {
// Issue #4123: the retention cap is a session count (raised to 1000) —
// there is no byte budget any more.
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 1000);
assert.equal(local.length, 50);
assert.equal(local.some(session => session.id === 's0'), false);
});
+13 -19
View File
@@ -18,34 +18,28 @@ export interface AiSessionsBackupBridge {
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/**
* Parse the live sessions store once, returning both whether it is usable and
* its sessions. The history is often several MB; the helpers below used to
* parse it twice per call, and mount ran them back to back (five parses).
*/
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
if (raw == null || raw === '') return { valid: false, sessions: [] };
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? { valid: true, sessions: parsed as AISession[] }
: { valid: false, sessions: [] };
} catch {
return { valid: false, sessions: [] };
}
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
return !parseSessionsStore(raw).valid;
if (raw == null || raw === '') return true;
try {
return !Array.isArray(JSON.parse(raw));
} catch {
return true;
}
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
return parseSessionsStore(raw).sessions;
if (isMissingOrCorruptSessionsStore(raw)) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
/**
File diff suppressed because it is too large. Load diff
+313 -254
View File
@@ -2,13 +2,8 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import type {
AIDraft,
AIPanelView,
@@ -16,6 +11,7 @@ import type {
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
@@ -27,23 +23,12 @@ import {
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import {
idbDeleteSessionIds,
idbPutMeta,
idbPutSessions,
isIndexedDbAvailable,
openAiSessionsIdb,
publishAiSessionsIdbChange,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
@@ -75,29 +60,11 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
}
/**
* Session ids explicitly deleted by the user (or by an authoritative cloud
* merge). Their disappearance from the persisted set must not trigger the
* pre-eviction backup generation — only cap-driven drops should.
*/
const intentionallyDeletedSessionIds = new Set<string>();
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
// Only the startup-light handle is imported here; the runtime registers it
// when created. Without a runtime there is no in-memory runtime state, but
// the reversible-privacy flag (set outside the runtime) and the
// main-process privacy mapping may still exist, so always clear those.
const runtime = peekAgentRuntimeCleanup();
for (const sessionId of sessionIds) {
intentionallyDeletedSessionIds.add(sessionId);
if (runtime) {
runtime.clearChatSession(sessionId);
} else {
setReversibleModelPrivacy(sessionId, false);
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
}
getAgentRuntime().clearChatSession(sessionId);
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
}
@@ -105,9 +72,8 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
const bridge = getAIBridge();
const runtime = peekAgentRuntimeCleanup();
for (const terminalSessionId of new Set(terminalSessionIds)) {
runtime?.clearTerminalSession(terminalSessionId);
getAgentRuntime().clearTerminalSession(terminalSessionId);
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
}
}
@@ -127,11 +93,8 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
// Child maintenance effects run before the provider's boot effects. Keep
// the live targets for post-hydration cleanup without adopting stale legacy
// history as authoritative state while IndexedDB is still loading.
if (!isAiSessionsHydrationComplete()) return;
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
const currentSessions = latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(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 —
@@ -218,53 +181,160 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
}
/** 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;
/**
* Maximum number of sessions kept in the persisted store. IndexedDB has no
* shared byte quota, so retention is a plain count cap; the cap only exists
* to keep history from growing without bound.
* 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_STORED_SESSIONS = 1000;
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;
/**
* Per-session message cap, read from user settings (clamped to 50–2000,
* default 200). Read on every write so a lowered cap trims the oldest
* messages on the next write; a raised cap stops further trimming.
* Best-effort: an unavailable storage layer keeps the default cap.
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
export function readAiSessionMessageCap(): number {
try {
return normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
);
} catch {
return DEFAULT_AI_SESSION_MESSAGE_CAP;
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Prune sessions before writing to the persisted store to bound retention.
* Only affects what is persisted — the in-memory state retains all messages
* until the session is reloaded.
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output 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 stripReasoningCiphertextFromMessage(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 stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
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 = stripReasoningCiphertextFromMessage(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 `messageCap` messages.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(
sessions: AISession[],
messageCap: number = readAiSessionMessageCap(),
): AISession[] {
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 > messageCap) {
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,
messageCap,
MAX_SESSION_MESSAGES,
);
const contextCompaction = s.contextCompaction
? {
@@ -289,119 +359,149 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
}
// ── IndexedDB persistence (issue #4123) ──
//
// Sessions are persisted one record per session. There is no global byte
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
// to defend a shared quota against the cost of serializing the whole store on
// every write, and both are gone with per-record writes. Only a count cap
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
/**
* 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.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const prepared = 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);
return { session, json: JSON.stringify(session) };
});
/** Last set of sessions known to be persisted, for per-record diffing. */
let lastPersistedSessionsById: Map<string, AISession> = new Map();
/** Serialized write queue: IDB operations never interleave. */
let idbWriteQueue: Promise<void> = Promise.resolve();
// Check whether retained history fits before preparing degradation. Use
// the existing JSON string-length budget including array delimiters, and
// avoid preparing unused stripped messages and serializing payloads again.
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, prepared.length - 1);
if (fullLength <= budgetBytes) {
return {
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
/** Test hook: reset diff state between tests. */
export function __resetIdbPersistenceForTests() {
lastPersistedSessionsById = new Map();
idbWriteQueue = Promise.resolve();
// Tests swap the fake indexedDB factory between cases; the cached open
// handle must not survive the swap.
resetAiSessionsIdbForTests();
// Tests simulate the post-boot state by default; the boot-gating cases
// call __resetAiSessionsHydrationForTests() to go back to pending.
markAiSessionsHydrationComplete();
}
// Reuse the full representation when the unchanged degradation ladder is
// needed, including on quota retries with a smaller budget.
const serialized = prepared.map(({ session, json }) => {
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
};
});
/** Test hook: resolve once every queued IndexedDB sync has settled. */
export function __flushIdbWritesForTests(): Promise<void> {
return idbWriteQueue.then(() => undefined, () => undefined);
}
// 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;
// ── Boot hydration signal (issue #4125 review) ──
//
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
// migration, disk-backup restore). Sync collection and the active-pointer
// validation must not run before it: a pre-hydration collect reads the
// stale legacy localStorage store and would overwrite newer IndexedDB
// records, and pointer validation against the initially-empty in-memory
// array would null every pointer before the real sessions arrive.
// 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;
}
let aiSessionsHydrationComplete = false;
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed 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 degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
export function isAiSessionsHydrationComplete(): boolean {
return aiSessionsHydrationComplete;
}
/** Called by useAIState's boot effect when the boot load settles. */
export function markAiSessionsHydrationComplete(): void {
aiSessionsHydrationComplete = true;
}
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
export function __resetAiSessionsHydrationForTests(): void {
aiSessionsHydrationComplete = false;
}
/** Seed the diff baseline after a boot load or migration. */
export function seedLastPersistedSessions(sessions: AISession[]) {
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* Read the persisted session set synchronously. Source of truth is the
* in-memory snapshot held by useAIState; this accessor returns the last set
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
* localStorage store (kept untouched for one release).
* 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 getPersistedAiSessionsSnapshot(): AISession[] {
if (lastPersistedSessionsById.size > 0) {
return Array.from(lastPersistedSessionsById.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
}
function queueIdbSessionSync(
changed: AISession[],
removedIds: string[],
prevById: Map<string, AISession>,
persisted: AISession[],
) {
if (changed.length === 0 && removedIds.length === 0) return;
idbWriteQueue = idbWriteQueue
.then(async () => {
if (!isIndexedDbAvailable()) return;
const db = await openAiSessionsIdb();
// Per-record write: only the changed sessions are put, only removed
// ids are deleted.
if (changed.length > 0) await idbPutSessions(db, changed);
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
await idbPutMeta(db, 'schemaVersion', 1);
await idbPutMeta(db, 'lastWriteAt', Date.now());
publishAiSessionsIdbChange();
})
.catch((error) => {
// IndexedDB failures must never break the in-memory session state.
// Reset the diff baseline so the next write retries every record, and
// fall back to the legacy localStorage key so a corrupt/unavailable
// IndexedDB still leaves a restorable trace (via the disk mirror).
console.warn('[AIState] IndexedDB session sync failed:', error);
lastPersistedSessionsById = new Map(prevById);
try {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
} catch {
// Legacy fallback is best-effort too.
}
});
}
// Durability mirror (issue #4015 follow-up): every write also schedules a
// debounced disk backup so a wiped/recreated profile does not take the chat
// history with it. The persisted JSON is mirrored; when the count cap evicts
// sessions, a PRE-eviction generation is written first so evicted chats stay
// recoverable from disk (the blind spot found in issue #4123).
// Durability mirror (issue #4015 follow-up): every successful store write
// also schedules a debounced disk backup so a wiped/recreated profile does
// not take the chat history with it. The exact JSON that landed in
// localStorage is mirrored (already byte-budgeted), throttled to at most one
// write per mutation batch.
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
@@ -410,14 +510,6 @@ const backupMirror = new DebouncedBackupMirror((json) => {
}
});
let aiSessionsBackupRecoveryPending = false;
/** Partial migration must not rotate away generations holding disk-only chats. */
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
aiSessionsBackupRecoveryPending = pending;
if (pending) backupMirror.reset();
}
export function flushBackupMirror() {
try {
backupMirror.flushNow();
@@ -428,11 +520,9 @@ export function flushBackupMirror() {
export function __resetBackupMirrorForTests() {
backupMirror.reset();
aiSessionsBackupRecoveryPending = false;
}
export function scheduleBackupMirror(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
} catch {
@@ -440,79 +530,48 @@ export function scheduleBackupMirror(json: string) {
}
}
/** Write one immediate backup generation (bypasses the debounce window). */
function writeImmediateBackupGeneration(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
backupMirror.flushNow();
} catch {
// Backup failures must never break chat persistence.
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
return syncedOk;
}
}
/**
* Perf (#4121): the exact same immutable sessions array is often written
* again by the debounced save path and by cloud-sync collection; skipping
* the re-stringify for a reference-identical set keeps that cheap. The
* per-record IDB diff below already makes the store write itself cheap.
*/
let lastMirroredSessions: AISession[] | null = null;
/**
* Persist sessions. Writes go to IndexedDB, one record per session, through a
* serialized queue; errors are logged, never thrown. Returns true when the
* persisted set was computed (in-memory state remains authoritative either
* way).
*/
export function writeSessionsForStorage(
sessions: AISession[],
options?: { deletedSessionIds?: readonly string[] },
): boolean {
try {
// Explicitly deleted ids never count as cap eviction (they also must not
// trigger a pre-eviction backup generation).
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
// Synced records are authoritative after restart: never prune them, or
// local retention would turn into cloud deletion on the next sync.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
)?.enabled;
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
const prevById = lastPersistedSessionsById;
const persistedIds = new Set(persisted.map(session => session.id));
// Count-cap eviction: sessions that drop out of the persisted set without
// being explicitly deleted get a pre-eviction disk generation containing
// the full pre-eviction store, so they remain recoverable.
const evicted = Array.from(prevById.entries())
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
.map(([, session]) => session);
intentionallyDeletedSessionIds.clear();
if (evicted.length > 0) {
writeImmediateBackupGeneration(JSON.stringify(sessions));
console.warn(
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
);
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)) {
scheduleBackupMirror(candidate.json);
return true;
}
// Per-record diff (synchronous: the baseline must advance even when
// IndexedDB is unavailable, and callers read the snapshot right after).
const changed = persisted.filter(session => prevById.get(session.id) !== session);
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
scheduleBackupMirror(JSON.stringify(persisted));
lastMirroredSessions = persisted;
}
queueIdbSessionSync(changed, removedIds, prevById, persisted);
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)
) {
scheduleBackupMirror(minimalCandidate.json);
return true;
} catch (error) {
console.warn('[AIState] Failed to persist AI sessions:', error);
return false;
}
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;
@@ -554,10 +613,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
export function prewarmAIStateStorageSnapshots() {
try {
// Sessions are no longer prewarmed from localStorage: the primary store is
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
// read-only legacy key would make the boot loader skip the IndexedDB
// contents ("never restore over non-empty live state").
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) ?? {};
@@ -1,6 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { HistoryEntry } from "./commandHistoryStore";
import {
STORAGE_KEY_COMMAND_HISTORY,
@@ -74,9 +73,7 @@ const {
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
fuzzyQueryHistory,
queryHistory,
queryRecentHistoryByCommand,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
@@ -329,172 +326,3 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
unsubscribe();
});
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
clearHistory();
recordCommand("echo fast-path", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
const parse = t.mock.method(JSON, "parse");
recordCommand("echo fast-path-2", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
// A peer window's save only needs the generation as well.
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
assert.equal(parse.mock.callCount(), 0);
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
const QUERY_NOW = 1_700_000_000_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
command, hostId: "host-1", os: "linux", frequency: 1,
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
};
}
function installHistoryEntries(entries: HistoryEntry[]): void {
clearHistory();
const raw = JSON.stringify({
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
}
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vim duplicate"),
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("Vim case"),
historyEntry("vim tied"),
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
historyEntry("unrelated", { frequency: 100 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
assert.deepEqual(queryHistory("missing"), []);
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
});
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vim a", { frequency: 2 }),
historyEntry("vim b", { frequency: 3 }),
historyEntry("vim c", { frequency: 4 }),
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
});
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vi alpha"),
historyEntry("vi beta"),
historyEntry("very indirect", { frequency: 2 }),
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
historyEntry("iv does not match"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory(""), []);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
});
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
const entries = [
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("cd '/work/new folder'"),
historyEntry("CD /work/tied"),
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
];
installHistoryEntries(entries);
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
});
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("av-x"),
historyEntry("avx"),
historyEntry(`v${"a".repeat(1900)}x`),
historyEntry("v-x"),
historyEntry("v/x"),
historyEntry("v_x"),
historyEntry("v x"),
historyEntry("vx adjacent"),
historyEntry("v missing second character"),
historyEntry("xv wrong order"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vx"), [
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
]);
assert.deepEqual(fuzzyQueryHistory("vv"), []);
});
for (const size of [4_000, 40_000]) {
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
// Unsorted scores/timestamps, with the best match at the end of the store.
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
}));
installHistoryEntries(entries);
queryHistory("missing"); // Warm the cache before measuring query operations.
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
const pow = t.mock.method(Math, "pow");
const lower = t.mock.method(String.prototype, "toLowerCase");
// node:test cannot mock methods directly on arrays (including Array.prototype).
const originalSort = Array.prototype.sort;
const sortedSizes: number[] = [];
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
sortedSizes.push(this.length);
return originalSort.call(this, compare);
};
t.after(() => { Array.prototype.sort = originalSort; });
const queries = [
() => queryHistory("vi", { limit: 5 }),
() => fuzzyQueryHistory("vi", { limit: 5 }),
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
];
for (const query of queries) {
const beforePow = pow.mock.callCount();
const beforeLower = lower.mock.callCount();
const result = query();
assert.deepEqual(result, expected);
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
}
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
});
}
+103 -126
View File
@@ -68,75 +68,33 @@ function loadStore(): HistoryStore {
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveIdleHandle: number | null = null;
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
/**
* The store's clear generation, read from its raw JSON without parsing the
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
* clearGeneration first (serializeStore); other layouts fall back to a parse.
*/
function readRawClearGeneration(raw: string | null): number | null {
if (!raw) return null;
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
if (match) return Number(match[1]);
try {
return readClearGeneration(JSON.parse(raw) as HistoryStore);
} catch {
return null;
}
}
function serializeStore(store: HistoryStore): HistoryStore {
// Keep clearGeneration first so readRawClearGeneration can find it.
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
}
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
// Only the generation is needed on this per-command path; parse the stored
// history just in the rare case where it must be adopted.
const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
}
function hasPendingSave(): boolean {
return saveTimer !== null || saveIdleHandle !== null;
return localStorageAdapter.write(STORAGE_KEY, store);
}
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves, then write when the renderer is idle: serializing the
// history right after Enter used to land in the middle of command output.
cancelPendingSave();
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null;
const persist = () => {
saveIdleHandle = null;
persistStoreNow(store);
};
if (typeof requestIdleCallback === "function") {
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
} else {
persist();
}
}, 500);
}
@@ -145,10 +103,6 @@ function cancelPendingSave(): void {
clearTimeout(saveTimer);
saveTimer = null;
}
if (saveIdleHandle !== null) {
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
saveIdleHandle = null;
}
}
/**
@@ -175,14 +129,26 @@ function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
// Fires in every other window for each save; read just the generation.
const incomingGeneration = readRawClearGeneration(rawNewValue);
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
@@ -190,7 +156,7 @@ function applyExternalCommandHistoryWrite(
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!hasPendingSave()) cachedStore = null;
if (!saveTimer) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
@@ -352,36 +318,6 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
argumentPrefix?: string;
}
interface RankedHistoryEntry {
entry: HistoryEntry;
score: number;
}
/**
* Keep only the best k distinct commands. Queries score each match once and
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
* for autocomplete). Entries arrive in store order for stable score ties.
*/
function retainHistoryMatch(
best: RankedHistoryEntry[],
entry: HistoryEntry,
score: number,
limit: number,
): void {
if (best.length >= limit && score <= best[best.length - 1].score) return;
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
if (duplicate >= 0) {
if (score <= best[duplicate].score) return;
best.splice(duplicate, 1);
}
// Insert after equal scores to preserve the original store order on ties.
const index = best.findIndex((candidate) => score > candidate.score);
if (best.length >= limit) best.pop();
best.splice(index < 0 ? best.length : index, 0, { entry, score });
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
@@ -396,17 +332,33 @@ export function queryHistory(
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) continue;
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
/**
@@ -424,7 +376,7 @@ export function fuzzyQueryHistory(
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
@@ -434,11 +386,24 @@ export function fuzzyQueryHistory(
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
scored.push({ entry, matchScore });
}
}
return best.map(({ entry }) => entry);
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
@@ -463,26 +428,38 @@ export function queryRecentHistoryByCommand(
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
continue;
return false;
}
if (excludeCommand && entry.command === excludeCommand) continue;
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
continue;
return false;
}
}
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
function getCurrentCommandToken(command: string): string {
@@ -558,28 +535,28 @@ function fuzzyScore(query: string, target: string): number {
if (query.length > target.length) return 0;
let score = 0;
let searchFrom = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
// Skip unmatched runs in native string search, especially for long commands.
const i = target.indexOf(query[queryIdx], searchFrom);
if (i < 0) return 0;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
score += 1;
prevMatchIdx = i;
searchFrom = i + 1;
}
return score;
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
@@ -47,45 +47,3 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
historyId: "history",
});
});
test("idle runtime polls do not notify subscribers until something changes", async () => {
const React = await import("react");
const { act, create } = await import("react-test-renderer");
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const idle = (takenAt: number): MonitorSnapshot => ({
historyMode: "replace",
runtimeId: "idle-poll-test",
epoch: 7,
takenAt,
running: [],
count: 0,
hostCount: 0,
activeSessions: [],
startedAt: {},
hosts: {},
});
let renders = 0;
function Probe() {
useMonitorSnapshot();
renders += 1;
return null;
}
// Later than anything earlier tests published, so this baseline wins.
const base = 1_000_000_000;
publishMonitorSnapshot(idle(base));
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(React.createElement(Probe));
});
const baseline = renders;
act(() => {
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
});
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
act(() => {
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
});
assert.equal(renders, baseline + 1, "a real change still notifies");
act(() => renderer.unmount());
});
+1 -23
View File
@@ -14,30 +14,8 @@ const empty: MonitorSnapshot = {
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
return value.running.length === 0
&& value.count === 0
&& value.hostCount === 0
&& value.activeSessions.length === 0
&& Object.keys(value.hosts).length === 0
&& Object.keys(value.startedAt).length === 0;
}
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
return isIdleMonitorSnapshot(previous)
&& isIdleMonitorSnapshot(next)
&& previous.epoch === next.epoch
&& previous.runtimeId === next.runtimeId
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
}
export function publishMonitorSnapshot(next: MonitorSnapshot) {
const merged = mergeMonitorSnapshotHistory(snapshot, next);
// The runtime is polled even when nothing is monitored; do not wake every
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
snapshot = merged;
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
for (const l of listeners) l();
}
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
-5
View File
@@ -33,7 +33,6 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input',
'textarea',
'select',
@@ -45,10 +44,6 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container',
].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
-41
View File
@@ -1,41 +0,0 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
+133 -2
View File
@@ -1,7 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { PluginViewTabStore } from './pluginViewTabStore';
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 = {
@@ -80,3 +184,30 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
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);
});
+246
View File
@@ -1,3 +1,11 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
@@ -73,3 +81,241 @@ export function reconcilePluginViewTabCatalog({
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;
}
}
@@ -1,41 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
-78
View File
@@ -1,78 +0,0 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
@@ -393,10 +393,12 @@ export function useSftpFollowTerminalCwd({
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
}
}, [
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork,
isVisible,
onGetTerminalCwd,
@@ -30,20 +30,3 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
unsubscribe();
assert.ok(versions.length >= 2);
});
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
const a = 'revision-a'; const b = 'revision-b';
terminalCwdStore.setCwd(a, '/same', 'osc7');
const revisionA = terminalCwdStore.getSessionRevision(a);
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
const globalVersion = terminalCwdStore.getVersion();
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
assert.equal(terminalCwdStore.getVersion(), globalVersion);
const revisionB = terminalCwdStore.getSessionRevision(b);
terminalCwdStore.prune(new Set([b]));
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
terminalCwdStore.prune(new Set());
});
+1 -14
View File
@@ -10,16 +10,11 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
class TerminalCwdStore {
private cwdBySession = new Map<string, string>();
private sourceBySession = new Map<string, TerminalCwdSource>();
private revisionBySession = new Map<string, number>();
private revision = 0;
private version = 0;
private listeners = new Set<Listener>();
getVersion = (): number => this.version;
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
getCwd = (sessionId: string | null | undefined): string | null => {
if (!sessionId) return null;
return this.cwdBySession.get(sessionId) ?? null;
@@ -44,10 +39,7 @@ class TerminalCwdStore {
const nextSource = next
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
: undefined;
if (current === next && currentSource === nextSource) {
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
return false;
}
if (current === next && currentSource === nextSource) return false;
if (next) {
this.cwdBySession.set(sessionId, next);
@@ -56,7 +48,6 @@ class TerminalCwdStore {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
}
this.revisionBySession.set(sessionId, ++this.revision);
this.version += 1;
for (const listener of this.listeners) {
listener();
@@ -70,13 +61,9 @@ class TerminalCwdStore {
if (!validSessionIds.has(sessionId)) {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
this.revisionBySession.delete(sessionId);
changed = true;
}
}
for (const id of this.revisionBySession.keys()) {
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
}
if (!changed) return;
this.version += 1;
for (const listener of this.listeners) {
@@ -1,230 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
let cwd = '/old'; let ready = true; let current = true;
const writes: string[] = [];
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'test-directory', confirmationMs: 10,
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
isCurrent: () => current, isReady: () => ready,
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
});
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
}
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
const ctx = setup(); const accepted: string[] = [];
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
assert.deepEqual(accepted, ['accepted']);
assert.deepEqual(ctx.published, ['/next']);
ctx.controller.dispose();
});
test('busy, control-character and stale sessions never receive bytes', async () => {
const ctx = setup(); ctx.busy();
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
const invalid = setup();
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
const stale = setup(); stale.stale();
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
});
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
let finish!: (path: string) => void;
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
ctx.busy(); finish('/next');
await assert.rejects(first, { code: 'busy' });
assert.deepEqual(ctx.writes, []);
});
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
let finish!: (cwd: string) => void;
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
const read = ctx.controller.refresh();
ctx.controller.dispose(); finish('/stale');
await assert.rejects(read, { code: 'unavailable' });
assert.deepEqual(ctx.published, []);
});
test('a failed cd keeps the observed cwd and never retries the write', async () => {
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
assert.ok(ctx.published.every(value => value === '/old'));
});
test('directory symlinks confirm against physical cwd', async () => {
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
await ctx.controller.change('/link', new AbortController().signal, () => {});
assert.equal(ctx.published.at(-1), '/real');
});
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
let finish!: (cwd: string) => void;
let reads = 0;
const ctx = setup({ cwd: () => {
reads++;
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
} });
const oldRead = ctx.controller.refresh();
const queued = ctx.controller.refresh();
finish('/before-cd');
await Promise.all([oldRead, queued]);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 2);
assert.equal(ctx.published.at(-1), '/after-cd');
ctx.controller.dispose();
});
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
const id = 'strict-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
finish('/actual-after-cd');
assert.equal(await pending, '/actual-after-cd');
assert.deepEqual(published, ['/actual-after-cd']);
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
});
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
const id = 'pre-write-osc7-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
t.after(() => controller.dispose());
const changing = controller.change('/after', new AbortController().signal, () => {});
await new Promise(resolve => setImmediate(resolve));
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
finish('/after');
await changing;
assert.deepEqual(published, ['/after']);
assert.equal(terminalCwdStore.getCwd(id), '/after');
});
for (const nextCwd of ['/new-osc7', '/original']) {
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
const id = 'same-session-osc7-' + nextCwd;
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/original', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
finish('/older-backend-result');
assert.equal(await pending, nextCwd);
assert.deepEqual(published, []);
});
}
for (const probe of ['fails', 'stalls'] as const) {
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
const id = 'osc7-confirm-' + probe;
const writes: string[] = [];
terminalCwdStore.setCwd(id, '/old', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
io: {
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write: data => {
writes.push(data);
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
},
},
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
});
t.after(() => controller.dispose());
await controller.change('/next', new AbortController().signal, () => {});
assert.deepEqual(writes, ["cd -- '/next'\r"]);
assert.equal(controller.hasConfirmedCwd(), true);
});
}
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
const id = 'probe-after-pre-write-osc7';
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
});
t.after(() => controller.dispose());
await controller.change('/after', new AbortController().signal, () => {});
assert.equal(terminalCwdStore.getCwd(id), '/after');
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
});
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
const id = 'osc7-revision-confirm-' + report;
terminalCwdStore.setCwd(id, '/next', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: {
cwd: async () => { throw new Error('no process cwd support'); },
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write() {
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
));
},
},
publish() {},
});
t.after(() => controller.dispose());
const change = controller.change('/next', new AbortController().signal, () => {});
if (report === 'fresh-unchanged') await change;
else await assert.rejects(change, { code: 'changeFailed' });
});
}
for (const cancellation of ['abort', 'dispose'] as const) {
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
const id = 'osc7-cancel-' + cancellation;
const abort = new AbortController();
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
realpath: async value => value,
write() { setImmediate(() => {
if (cancellation === 'abort') abort.abort(); else controller.dispose();
terminalCwdStore.setCwd(id, '/next', 'osc7');
}); } },
publish() {},
});
t.after(() => controller.dispose());
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
});
}
@@ -1,162 +0,0 @@
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { terminalCwdStore } from './terminalCwdStore';
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
/** One registration represents one connected interactive shell generation. */
export function createTerminalDirectoryController(options: {
sessionId: string;
io: TerminalDirectoryIO;
isCurrent: () => boolean;
isReady: () => boolean;
publish: (cwd: string) => void;
confirmationMs?: number;
}) {
let disposed = false;
let changing = false;
let confirmed = false;
let refreshing: Promise<string> | null = null;
let refreshAgain = false;
const lifetime = new AbortController();
const current = () => !disposed && options.isCurrent();
function check(signal?: AbortSignal) {
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
}
function ready() {
check();
if (!options.isReady()) throw new DirectoryPickerError('busy');
}
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const finish = (error?: unknown, value?: T) => {
clearTimeout(timer);
lifetime.signal.removeEventListener('abort', cancel);
signal?.removeEventListener('abort', cancel);
if (error) reject(error); else resolve(value!);
};
const cancel = () => finish(new DirectoryPickerError('unavailable'));
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
lifetime.signal.addEventListener('abort', cancel, { once: true });
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted || lifetime.signal.aborted) cancel();
task.then(value => finish(undefined, value), error => finish(error));
});
}
async function refresh(submittedRevision?: number): Promise<string> {
check();
const activeShellCwd = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
? cwd : null;
};
// A process probe may read the outer PTY shell while a nested shell is
// active. OSC 7 remains authoritative across debounce delays, completion
// events and picker remounts. Only a cd submission may probe past its own
// pre-write report while waiting for fresh confirmation.
const reported = activeShellCwd();
if (reported) { confirmed = true; return reported; }
if (refreshing) {
// Output may settle while a pre-cd query is still in flight. Keep one
// follow-up query so that its older result cannot be the last update.
refreshAgain = true;
return refreshing;
}
const pending = (async () => {
const cwd = await bounded(options.io.cwd());
check();
const reported = activeShellCwd();
const live = terminalCwdStore.getCwd(options.sessionId);
confirmed = true;
if (reported) return reported;
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
return cwd;
})();
refreshing = pending;
try { return await pending; } finally {
if (refreshing === pending) refreshing = null;
if (refreshAgain && current()) {
refreshAgain = false;
void refresh().catch(() => {});
}
}
}
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
ready();
if (changing) throw new DirectoryPickerError('busy');
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
if (!intent) throw new DirectoryPickerError('unsupportedPath');
changing = true;
let confirmationFinished = false;
try {
const canonical = await bounded(options.io.realpath(path, signal), signal);
check(signal);
ready(); // The shell can become busy while resolving a remote symlink.
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
options.io.write(intent.command + '\r');
accepted();
const deadline = Date.now() + (options.confirmationMs ?? 8000);
const hasFreshOsc7 = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (cwd === path || cwd === canonical);
};
const pause = () => new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
const timer = setTimeout(done, 200);
lifetime.signal.addEventListener('abort', done, { once: true });
});
const confirmFromProbe = async () => {
while (!confirmationFinished && Date.now() < deadline) {
check(signal);
if (hasFreshOsc7()) return;
if (options.isReady()) {
try {
const cwd = await refresh(submittedRevision);
check(signal);
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
} catch (error) {
if (!current() || signal.aborted) throw error;
}
}
await pause();
}
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
};
// OSC 7 is authoritative even when process cwd lookup is unavailable or
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
const confirmFromOsc7 = async () => {
while (!confirmationFinished) {
check(signal);
if (hasFreshOsc7()) return;
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
await pause();
}
};
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
check(signal);
confirmed = true;
} finally { confirmationFinished = true; changing = false; }
}
return {
refresh: () => refresh(),
hasConfirmedCwd: () => confirmed,
async home() {
check();
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
check(); return result;
},
async list(path: string, signal: AbortSignal) {
check(signal);
const result = await bounded(options.io.list(path, signal), signal);
check(signal);
return result;
},
change,
dispose() { disposed = true; lifetime.abort(); },
};
}
@@ -1,87 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import {
observeTerminalDirectory, registerTerminalDirectory,
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
} from './terminalDirectoryRegistry';
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let cwd = '/before'; let reads = 0;
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
io: { cwd: async () => { reads++; return cwd; },
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
t.after(() => { stopObserving(); unregister(); });
cwd = '/after-manual-cd';
// Multiple prompt/output chunks produce just one read. No trusted prompt
// detection, command-completed callback, or parsed `cd` is involved.
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(80);
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(149);
assert.equal(reads, 0);
t.mock.timers.tick(1);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(published, ['/after-manual-cd']);
assert.equal(reads, 1);
t.mock.timers.tick(60_000);
assert.equal(reads, 1, 'no polling after output has settled');
});
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const id = 'nested-shell-output';
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
// The PTY process stays in its original directory while a child shell
// runs in /. Its cwd is only a fallback for shells without OSC 7.
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregister = registerTerminalDirectory(id, controller);
const stopObserving = observeTerminalDirectory(id);
t.after(() => { stopObserving(); unregister(); });
scheduleObservedTerminalDirectoryRefresh(id);
terminalCwdStore.setCwd(id, '/', 'osc7');
t.mock.timers.tick(150);
await new Promise(resolve => setImmediate(resolve));
assert.equal(terminalCwdStore.getCwd(id), '/');
// Later output chunks, prompt completion and reopening/manual refresh must
// not treat that same active-shell report as stale merely due to timing.
scheduleObservedTerminalDirectoryRefresh(id);
t.mock.timers.tick(150);
refreshObservedTerminalDirectory(id);
assert.equal(await controller.refresh(), '/');
assert.deepEqual(published, []);
assert.equal(terminalCwdStore.getSource(id), 'osc7');
});
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let reads = 0;
const controller = createTerminalDirectoryController({
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} }, publish() {},
});
const unregister = registerTerminalDirectory('hidden-chips', controller);
t.after(unregister);
const stopObserving = observeTerminalDirectory('hidden-chips');
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
stopObserving();
t.mock.timers.tick(1000);
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
t.mock.timers.tick(1000);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 0);
});
@@ -1,58 +0,0 @@
import type { TerminalDirectoryController } from './terminalDirectoryController';
const sessions = new Map<string, TerminalDirectoryController>();
const consumers = new Map<string, number>();
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
const OUTPUT_SETTLE_MS = 150;
function cancelScheduledRefresh(id: string) {
const timer = scheduledRefreshes.get(id);
if (timer !== undefined) clearTimeout(timer);
scheduledRefreshes.delete(id);
}
const listeners = new Set<() => void>();
export const subscribeTerminalDirectories = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
cancelScheduledRefresh(id);
sessions.set(id, controller);
listeners.forEach(listener => listener());
return () => {
controller.dispose();
if (sessions.get(id) !== controller) return;
cancelScheduledRefresh(id);
sessions.delete(id);
listeners.forEach(listener => listener());
};
}
export function observeTerminalDirectory(id: string) {
consumers.set(id, (consumers.get(id) ?? 0) + 1);
return () => {
const next = (consumers.get(id) ?? 1) - 1;
if (next) consumers.set(id, next);
else { consumers.delete(id); cancelScheduledRefresh(id); }
};
}
/** Called by the existing command completion path, only while chips are visible. */
export function refreshObservedTerminalDirectory(id: string) {
cancelScheduledRefresh(id);
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
}
/**
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
* completion. Debounce actual output/submission activity instead of polling or
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
* the bound shell's process cwd only as a fallback.
*/
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
if (!consumers.has(id) || !sessions.has(id)) return;
cancelScheduledRefresh(id);
scheduledRefreshes.set(id, setTimeout(() => {
scheduledRefreshes.delete(id);
refreshObservedTerminalDirectory(id);
}, OUTPUT_SETTLE_MS));
}
@@ -3,37 +3,6 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = [];
+1 -27
View File
@@ -1,10 +1,7 @@
type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
@@ -31,40 +28,19 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) {
handler();
}
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false);
}
};
};
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
@@ -90,9 +66,7 @@ export const createTerminalReconnectRegistry = () => {
return {
register,
inspect,
request,
requestIfAvailable,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+4 -40
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import {
normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
export function useTerminalSidePanelTabOrder(): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout;
@@ -111,23 +111,6 @@ export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool
resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const {
layout,
setPlacement,
@@ -138,28 +121,9 @@ export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool
partition,
} = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
defaults,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
);
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback(
@@ -1,215 +0,0 @@
/**
* Minimal in-memory IndexedDB stub covering the exact subset used by
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
* general-purpose IDB implementation — extend deliberately if the wrapper
* grows new usage.
*/
type Resolve = () => void;
const microtask2 = (fn: Resolve) => {
queueMicrotask(() => queueMicrotask(fn));
};
export class FakeRequest<T> {
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
result: T;
error: Error | null = null;
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
this.result = result;
if (mode === 'failure') {
this.error = error ?? new Error('fake idb failure');
queueMicrotask(() => this.onerror?.(this));
return;
}
queueMicrotask(() => {
if (mode === 'upgrade') this.onupgradeneeded?.(this);
this.onsuccess?.(this);
});
}
}
interface StoreSpec {
name: string;
keyPath: string;
}
export class FakeObjectStore {
/** Instrumentation for per-record write assertions. */
putCount = 0;
deleteCount = 0;
constructor(
public readonly spec: StoreSpec,
public readonly records: Map<string, unknown>,
) {}
put(value: unknown): FakeRequest<IDBValidKey> {
this.putCount += 1;
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
this.records.set(key, value);
return new FakeRequest(key);
}
delete(key: IDBValidKey): FakeRequest<undefined> {
this.deleteCount += 1;
this.records.delete(String(key));
return new FakeRequest(undefined);
}
clear(): FakeRequest<undefined> {
this.records.clear();
return new FakeRequest(undefined);
}
get(key: IDBValidKey): FakeRequest<unknown> {
return new FakeRequest(this.records.get(String(key)));
}
getAll(): FakeRequest<unknown[]> {
return new FakeRequest(Array.from(this.records.values()));
}
}
export class FakeTransaction {
oncomplete: Resolve | null = null;
onabort: ((ev?: unknown) => void) | null = null;
onerror: ((ev?: unknown) => void) | null = null;
error: Error | null = null;
constructor(
private readonly database: FakeIndexedDb,
private readonly storeName: string,
private readonly fail: boolean,
) {
microtask2(() => {
if (this.fail) {
this.error = new Error('fake transaction failure');
this.onerror?.(this);
this.onabort?.(this);
return;
}
this.oncomplete?.();
});
}
objectStore(name: string): FakeObjectStore {
if (name !== this.storeName) {
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
}
return this.database.getOrCreateStore(name);
}
}
export class FakeIndexedDb {
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
constructor() {
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
}
private readonly storeSpecs = new Map<string, string>();
private readonly records = new Map<string, Map<string, unknown>>();
/** Instrumentation per store: { put, delete } counters. */
readonly opCounts = new Map<string, { put: number; delete: number }>();
onclose: (() => void) | null = null;
onversionchange: (() => void) | null = null;
createObjectStore(name: string, options?: { keyPath?: string }) {
this.objectStoreNames.add(name);
this.storeSpecs.set(name, options?.keyPath ?? 'key');
return { name };
}
getOrCreateStore(name: string): FakeObjectStore {
if (!this.storeSpecs.has(name)) {
this.storeSpecs.set(name, 'key');
this.objectStoreNames.add(name);
}
if (!this.records.has(name)) this.records.set(name, new Map());
const spec = { name, keyPath: this.storeSpecs.get(name)! };
const store = new FakeObjectStore(spec, this.records.get(name)!);
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
// Aggregate counts lazily via proxy-free bookkeeping on each op.
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
counts.put += 1;
this.opCounts.set(name, counts);
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
counts.delete += 1;
this.opCounts.set(name, counts);
return originalDelete(key);
};
return store;
}
transaction(storeName: string, _mode?: string): FakeTransaction {
return new FakeTransaction(this, storeName, false);
}
close() {
this.onclose?.();
}
/** Direct read for assertions (bypasses the async wrapper). */
dumpStore(name: string): unknown[] {
return Array.from(this.records.get(name)?.values() ?? []);
}
}
export interface FakeIndexedDbHandle {
db: FakeIndexedDb;
openCount: number;
restore: () => void;
}
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
const handle: FakeIndexedDbHandle = {
db: new FakeIndexedDb(),
openCount: 0,
restore: () => {
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
},
};
if (options.onOperation) {
const onOperation = options.onOperation;
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
handle.db.getOrCreateStore = (name: string) => {
const store = originalGet(name);
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
onOperation(name, 'put');
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
onOperation(name, 'delete');
return originalDelete(key);
};
return store;
};
}
(globalThis as { indexedDB?: unknown }).indexedDB = {
open(_name: string, _version: number) {
handle.openCount += 1;
if (options.failOpen) {
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
// Fail only the first N opens, then recover (boot-retry scenarios).
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
},
};
return handle;
}
-29
View File
@@ -12,7 +12,6 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
@@ -35,10 +34,6 @@ import {
normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
@@ -92,12 +87,6 @@ export function useAISettingsState() {
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
)
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
@@ -208,12 +197,6 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setAiSessionMessageCap = useCallback((value: number) => {
const normalizedValue = normalizeAiSessionMessageCap(value);
setAiSessionMessageCapRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -294,14 +277,6 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
setAiSessionMessageCapRaw(
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
),
);
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -365,8 +340,6 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -400,8 +373,6 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -1,489 +0,0 @@
/**
* Boot-scenario regression tests for the IndexedDB migration review fixes
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
* P2① baseline seeding after an IndexedDB failure,
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
* deletion.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
} from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { getAISessionsSnapshot } from './aiSessionsStore';
import {
__flushIdbWritesForTests,
__resetAiSessionsHydrationForTests,
__resetBackupMirrorForTests,
__resetIdbPersistenceForTests,
getPersistedAiSessionsSnapshot,
flushBackupMirror,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots';
import {
idbDeleteSessionIds,
AI_SESSIONS_META_MIGRATION_KEY,
idbPutSessions,
loadOrMigrateAiSessions,
openAiSessionsIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'global' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
function installDom() {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
return dom;
}
interface Mounted {
ai: ReturnType<typeof useAIState>;
root: ReturnType<typeof createRoot>;
container: HTMLDivElement;
}
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
const holder: { ai?: ReturnType<typeof useAIState> } = {};
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
const mountAi = React.useRef(ai);
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
return null;
}
function Harness() {
holder.ai = useAIState();
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => { root.render(React.createElement(Harness)); });
return { ai: holder.ai!, root, container };
}
async function unmount(mounted: Mounted, dom: JSDOM) {
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
dom.window.close();
}
function resetBootState() {
// Post-boot defaults first, then back to pending so every test starts
// from the pre-hydration state its own hook instance controls.
__resetIdbPersistenceForTests();
__resetAiSessionsHydrationForTests();
// Module-level snapshots leak across tests (the hook's initial state and
// the boot merge path read them) — reset to the cold-start state. The
// map snapshot must be null so the hook falls back to localStorage.
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
setLatestAIDraftsByScopeSnapshot({});
setLatestAIPanelViewByScopeSnapshot({});
}
test.beforeEach(() => {
__resetBackupMirrorForTests();
resetBootState();
});
test.afterEach(() => { __resetBackupMirrorForTests(); });
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const deleted = makeSession('deleted-chat', 1);
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
return { ok: true, json: JSON.stringify([deleted]) };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
const mounted = await mountAiState();
try {
flushBackupMirror();
await __flushIdbWritesForTests();
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
assert.equal(backupReads, 0);
assert.deepEqual(backupWrites, []);
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(idb.db.dumpStore('sessions'), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const backup = [makeSession('backup-chat', 1)];
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await __flushIdbWritesForTests();
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const chunk = makeSession('chunk-chat', 2);
const diskOnly = makeSession('disk-only-chat', 1);
const unmarked = makeSession('unmarked-deleted-chat', 0);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let recoveryAvailable = false;
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
return { ok: false };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
let mounted = await mountAiState();
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
try {
assert.equal(backupReads, 1);
assert.equal(hasMarker(), false);
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
flushBackupMirror();
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
// Ordinary edits and unload must also preserve the recovery generation.
await act(async () => {
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
});
await new Promise(resolve => setTimeout(resolve, 600));
await __flushIdbWritesForTests();
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
assert.deepEqual(backupWrites, []);
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
assert.equal(updated.messages.length, 1);
assert.equal(hasMarker(), false);
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
resetBootState();
recoveryAvailable = true;
mounted = await mountAiState();
await __flushIdbWritesForTests();
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
assert.equal(hasMarker(), true);
const expected = [updated, chunk, diskOnly];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
flushBackupMirror();
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
}
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const stale: AISession = {
...makeSession('closed-chat', 1),
scope: { type: 'terminal', targetId: 'closed-terminal' },
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
};
const current: AISession = {
...stale,
updatedAt: 2,
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
await idbPutSessions(await openAiSessionsIdb(), [current]);
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let snapshotAfterMaintenance: AISession[] | null | undefined;
let putsAfterMaintenance = 0;
const mounted = await mountAiState((ai) => {
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
snapshotAfterMaintenance = latestAISessionsSnapshot;
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
});
try {
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
await __flushIdbWritesForTests();
const expected = [{ ...current, scopeDangling: true }];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
const overflowJson = JSON.stringify(overflow);
const split = Math.floor(overflowJson.length / 2);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
const mounted = await mountAiState();
try {
const expected = [primary, ...overflow.slice(0, 2)];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const diskOnly = makeSession('disk-only-chat', 2);
const deleted = makeSession('deleted-chat', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
let backupReads = 0;
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
});
const mounted = await mountAiState();
try {
assert.equal(backupReads, 1);
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
await act(async () => {
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
});
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
const dom = installDom();
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
try {
// One open retry happened inside the boot effect: the legacy set now
// lives in IndexedDB (old code never retried and left the store empty).
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
assert.deepEqual(
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
['chat-a', 'chat-b'],
);
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
const dom = installDom();
const failing = installFakeIndexedDb({ failOpen: true });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
try {
// The retry open also failed: nothing was persisted, and the diff
// baseline must remain unset.
assert.deepEqual(failing.db.dumpStore('sessions'), []);
// IndexedDB recovers, then the user edits ONE session. The full set
// must reach the store, not only the edited record.
failing.restore();
resetAiSessionsIdbForTests();
recovered = installFakeIndexedDb();
await act(async () => {
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
});
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
await act(async () => {});
await __flushIdbWritesForTests();
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
if (recovered) recovered.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2②: pointer validation waits for hydration ──
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
const dom = installDom();
const source = makeSession('chat-1', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
JSON.stringify({ 'terminal:t1': 'chat-1' }),
);
const mounted = await mountAiState();
try {
// Old code nulled the pointer here: the validation effect ran against
// the initially-empty in-memory session array.
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
assert.equal(
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
'chat-1',
);
// After hydration the session is restored and the pointer is intact.
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
} finally {
await unmount(mounted, dom);
}
});
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
const dom = installDom();
installFakeIndexedDb({ failOpen: true });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
const backup = [makeSession('backup-chat', 5)];
const warns: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warns.push(args); };
try {
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await act(async () => {});
// The backup generation became the recovery source (old code: the
// failing IndexedDB recheck rejection was swallowed and the restore
// never ran).
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
// The unopenable-store error was surfaced, not swallowed.
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
} finally {
await unmount(mounted, dom);
}
} finally {
console.warn = originalWarn;
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
resetAiSessionsIdbForTests();
}
});
// ── P2④: an empty getAll after hydration is an intentional deletion ──
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const source = makeSession('chat-1', 1);
await idbPutSessions(await openAiSessionsIdb(), [source]);
const mounted = await mountAiState();
try {
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
// Another window deletes the last chat and announces the change. The
// announcement must come from a second BroadcastChannel: a channel never
// delivers to itself (the module-level channel is the receiving window).
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
await act(async () => {
otherWindow.postMessage(Date.now());
await new Promise((resolve) => setTimeout(resolve, 25));
});
otherWindow.close();
// Old code early-returned on the empty getAll and kept the deleted
// session alive locally.
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
+4 -11
View File
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
@@ -121,18 +121,11 @@ for (const newerCount of [49, 50]) {
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage
// blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove();
// Reload from the read-only legacy store (the stand-in for what the
// migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
await act(async () => root.unmount()); container.remove(); dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
@@ -1,56 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession = {
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
};
setLatestAISessionsSnapshot([source]);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
let ai: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => {
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
});
const updated = sessions.find(value => value.id === source.id)!;
assert.equal(updated.messages.length, enabled ? 502 : 500);
assert.equal(updated.messages[0].id, 'system');
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
assert.equal(updated.externalSessionId, 'native-thread');
} finally {
await act(async () => { root.unmount(); });
container.remove();
dom.window.close();
}
});
}
+62 -258
View File
@@ -14,8 +14,6 @@ import {
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
@@ -66,23 +64,17 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
flushBackupMirror,
getAIBridge,
getDraftUploadGeneration,
getPersistedAiSessionsSnapshot,
isAiSessionsHydrationComplete,
latestAIActiveSessionMapSnapshot,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getDraftUploadGeneration,
latestAIActiveSessionMapSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
markAiSessionsHydrationComplete,
prepareRestoredSessions,
readAiSessionMessageCap,
rebindActiveSessionMap,
scheduleBackupMirror,
seedLastPersistedSessions,
setAiSessionsBackupRecoveryPending,
rebindActiveSessionMap,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -91,20 +83,6 @@ import {
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import {
AI_SESSIONS_IDB_VERSION,
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbGetAllSessions,
idbPutMeta,
idbPutSessions,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
publishAiSessionsIdbChange,
readActiveSessionMapFromIdb,
subscribeAiSessionsIdbChange,
} from './aiSessionIdbStorage';
import {
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
@@ -200,14 +178,10 @@ export function useAIState() {
);
// ── Sessions ──
// Memory-only initial state: the primary store is IndexedDB and loads
// asynchronously in the boot effect below (issue #4123). The legacy
// localStorage key is read-only fallback and must not pre-seed stale data.
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
// Issue #4125 review P2②: flips true when the boot load settles. The
// active-pointer validation effect waits for it — the initially-empty
// in-memory session array must never null the persisted pointers.
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
);
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
useEffect(() => {
@@ -215,158 +189,48 @@ export function useAIState() {
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
return;
}
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
let cleanupAfterHydration = false;
void (async () => {
try {
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
const boot = await loadOrMigrateAiSessions(
async () => {
// Legacy retention split history between the primary key, JSON
// chunks, and explicitly marked disk-only records. Resolve all
// slices before the one-shot migration records completion.
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
const chunk = localStorageAdapter.readString(key);
if (chunk == null) break;
chunks.push(chunk);
}
let legacy = mergeImportedSessions(
readLiveSessionsArray(chunks.join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
if (diskOnlyIds.size > 0) {
try {
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
if (backup?.ok) {
legacy = mergeImportedSessions(
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
legacy,
);
}
} catch (error) {
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
}
}
return JSON.stringify(legacy);
},
[...diskOnlyIds],
);
setAiSessionsBackupRecoveryPending(boot.migrationPending);
if (cancelled) return;
if (boot.idbFailed && boot.sessions.length > 0) {
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
// Persist it best-effort (one re-open retry) BEFORE seeding the
// diff baseline; if the store is still unreachable, leave the
// baseline unset so the next edit performs a full write instead of
// a diff that would drop the un-persisted sessions after IndexedDB
// recovers.
try {
const db = await openAiSessionsIdb();
if (!cancelled) {
await idbPutSessions(db, boot.sessions);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
publishAiSessionsIdbChange();
seedLastPersistedSessions(boot.sessions);
}
} catch (error) {
console.warn(
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
+ ' the diff baseline stays unset so the next edit rewrites every record:',
error,
);
}
} else {
seedLastPersistedSessions(boot.sessions);
}
// Restore the mirrored active-pointer map when the localStorage key
// is missing (fresh profile restored from IndexedDB).
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
const mirrored = await readActiveSessionMapFromIdb();
if (cancelled) return;
if (mirrored && Object.keys(mirrored).length > 0) {
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
setLatestAIActiveSessionMapSnapshot(mirrored);
setActiveSessionIdMapRaw(mirrored);
}
}
const bootSessions = boot.sessions;
if (bootSessions.length > 0) {
const live = latestAISessionsSnapshot ?? sessionsRef.current;
if (live.length > 0) {
// Sessions were created before the async load finished: merge
// (live records win by id) instead of clobbering either side.
const byId = new Map(bootSessions.map(session => [session.id, session]));
for (const session of live) byId.set(session.id, session);
const merged = Array.from(byId.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
setLatestAISessionsSnapshot(merged);
sessionsRef.current = merged;
setSessionsRaw(merged);
writeSessionsForStorage(merged);
return;
}
setLatestAISessionsSnapshot(bootSessions);
sessionsRef.current = bootSessions;
setSessionsRaw(bootSessions);
scheduleBackupMirror(JSON.stringify(bootSessions));
cleanupAfterHydration = true;
return;
}
// A previously initialized empty IDB is a committed deletion. An
// incomplete migration must retry only its marked disk-only records.
if (boot.authoritativeEmpty || boot.migrationPending) return;
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
// backup remains the last restore step. Only when the legacy store
// is also missing or corrupt — a valid empty legacy array is an
// intentional wipe and stays authoritative.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover persisted storage and in-memory snapshot/React state —
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
// Issue #4125 review P2③: one open retry already happened inside
// the boot load. If the store is still unopenable, do not let the
// recheck rejection reach the outer catch — the disk backup below
// is the recovery source.
try {
if ((await idbGetAllSessions()).length > 0) return;
} catch (error) {
console.warn(
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
+ ' applying the newest backup generation as the recovery source:',
error,
);
}
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
cleanupAfterHydration = true;
// Restore does not change live terminals/workspaces, so the orphan-
// cleanup effect will not run. Reuse the last known live target set
// (empty is valid: nothing live) so chats bound to missing scopes
// become dangling and /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
@@ -376,25 +240,10 @@ export function useAIState() {
?? {},
);
console.info(
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
);
} catch (error) {
// Boot load must never break startup — but it must not vanish
// silently either (issue #4125 review P2③).
console.warn('[AIState] AI session boot load failed:', error);
} finally {
markAiSessionsHydrationComplete();
if (!cancelled) {
// Maintenance may have mounted before hydration with unchanged live
// targets. Replay it now against the authoritative boot history.
if (cleanupAfterHydration) {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
}
setSessionsHydrated(true);
}
} catch {
// Backup restore must never break startup.
}
})();
return () => {
@@ -463,9 +312,6 @@ export function useAIState() {
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
// store (localStorage stays written at the mutation sites as before).
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
@@ -477,11 +323,6 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
// the initially-empty in-memory array; validating pointers against it
// would null every pointer and break workspace restore. No pointer
// writes before hydration completes.
if (!sessionsHydrated) return;
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -510,7 +351,7 @@ export function useAIState() {
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}, [sessions, activeSessionIdMap, sessionsHydrated]);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
@@ -702,8 +543,7 @@ export function useAIState() {
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
const key = e.key;
switch (key) {
switch (e.key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) {
@@ -795,10 +635,7 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_SESSIONS: {
// Session writes no longer land in localStorage (issue #4123);
// the cross-window signal is the IndexedDB change channel and the
// storage event only fires as a legacy fallback.
const nextSessions = getPersistedAiSessionsSnapshot();
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
@@ -840,7 +677,8 @@ export function useAIState() {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot(),
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
@@ -867,37 +705,6 @@ export function useAIState() {
};
}, []);
// Cross-window AI session sync: session writes land in IndexedDB now, so
// the localStorage `storage` event no longer fires for them. Other windows
// announce their writes on a BroadcastChannel; the receiver reloads the
// store from IndexedDB.
useEffect(() => subscribeAiSessionsIdbChange(() => {
void (async () => {
try {
const next = await idbGetAllSessions();
if (next.length === 0) {
if (!isAiSessionsHydrationComplete()) return;
// Issue #4125 review P2④: after hydration an empty store is an
// intentional cross-window deletion — adopt it instead of keeping
// the deleted session alive locally (its next edit would
// resurrect it). The pointer-validation effect clears the
// now-stale pointers. Only the pre-hydration window keeps the
// early-return: the boot load owns the store view until it
// settles.
setLatestAISessionsSnapshot([]);
seedLastPersistedSessions([]);
setSessionsRaw([]);
return;
}
setLatestAISessionsSnapshot(next);
seedLastPersistedSessions(next);
setSessionsRaw(next);
} catch {
// Cross-window refresh is best-effort.
}
})();
}), []);
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
@@ -923,8 +730,8 @@ export function useAIState() {
}, []);
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
writeSessionsForStorage(next, { deletedSessionIds });
const persistSessions = useCallback((next: AISession[]) => {
writeSessionsForStorage(next);
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -1069,7 +876,7 @@ export function useAIState() {
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next, [sessionId]);
persistSessions(next);
return next;
});
if (scopeKey) {
@@ -1113,7 +920,7 @@ export function useAIState() {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next, removedSessionIds);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
@@ -1209,10 +1016,11 @@ export function useAIState() {
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
setSessionsRaw(next);
sessionsRef.current = persisted;
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
@@ -1221,7 +1029,7 @@ export function useAIState() {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? next;
?? persisted;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1229,7 +1037,7 @@ export function useAIState() {
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return nextSessions.filter(session => importedIds.has(session.id)).length;
return persisted.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
@@ -1245,27 +1053,21 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Local-only retention. Synced history must match the full-history storage
// policy. The in-memory bound follows the user-configurable per-session cap
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
// so the default behavior is unchanged and RAM stays bounded when the user
// lowers the persisted cap.
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > inMemoryCap) {
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
@@ -1503,11 +1305,11 @@ export function useAIState() {
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds);
if (!isAiSessionsHydrationComplete()) return;
const nextSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1559,7 +1361,8 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap,
@@ -1604,7 +1407,8 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap,
sessions: currentSessions,
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
@@ -175,21 +175,17 @@ for (const newerCount of [49, 50]) {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove();
// Seed the read-only legacy store with the persisted set (the stand-in
// for what the migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
@@ -227,7 +223,7 @@ for (const viewingScope of [
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = getPersistedAiSessionsSnapshot();
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
@@ -11,10 +11,6 @@ import {
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
import { readFileSync } from "node:fs";
import {
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from "./settingsChromeStore.ts";
function createInlineStyle() {
const values = new Map<string, string>();
@@ -102,193 +98,6 @@ test("syncActiveChromeTheme skips surface refresh when the active theme fingerpr
}
});
for (const preference of ["system", "light", "dark"] as const) {
for (const chromeType of ["light", "dark"] as const) {
test(`syncActiveChromeTheme uses ${preference === "system" ? "system" : chromeType} native theme for ${preference} preference and ${chromeType} chrome`, () => {
const globals = globalThis as typeof globalThis & { document?: Document; window?: Window };
const originalDocument = globals.document;
const originalWindow = globals.window;
const originalSnapshot = getSettingsChromeSnapshot();
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === chromeType);
assert.ok(theme);
const themeCalls: string[] = [];
const backgroundCalls: string[] = [];
globals.document = {
documentElement: {
dataset: {},
classList: { remove: () => {}, add: () => {} },
removeAttribute: () => {},
},
getElementById: () => null,
createElement: () => ({}),
head: { appendChild: () => {} },
querySelector: () => null,
} as unknown as Document;
globals.window = {
netcatty: {
setTheme: (value: string) => themeCalls.push(value),
setBackgroundColor: (value: string) => backgroundCalls.push(value),
},
} as unknown as Window & typeof globalThis;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: preference });
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an active chrome theme");
});
assert.deepEqual(themeCalls, [preference === "system" ? "system" : chromeType]);
assert.deepEqual(backgroundCalls, [theme.colors.background]);
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
if (originalDocument) {
globals.document = originalDocument;
} else {
delete globals.document;
}
if (originalWindow) {
globals.window = originalWindow;
} else {
delete globals.window;
}
}
});
}
}
// A hanging jsdom/React render must fail this test quickly instead of
// consuming the whole CI job budget.
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const themeSources: string[] = [];
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: async (source: string) => { themeSources.push(source); return true; },
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: (source: string) => {
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
return Promise.resolve(true);
},
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
// The application settings effect runs first on an OS preference change.
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
backgrounds.push('application-background');
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
syncActiveChromeTheme(null, () => {
document.documentElement.className = 'light';
backgrounds.push('application-background');
});
assert.equal(document.documentElement.className, 'light');
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
assert.equal(backgrounds.at(-1), 'application-background');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
const { JSDOM } = await import('jsdom');
const { act, createElement } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
globalThis.window = testWindow;
globalThis.document = dom.window.document;
testWindow.netcatty = {
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
} as unknown as NetcattyBridge;
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
assert.ok(theme);
const applyAppTheme = () => {};
function Harness() {
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
return null;
}
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
let renderer: ReturnType<typeof createRoot> | undefined;
try {
await act(async () => {
renderer = createRoot(document.createElement('div'));
renderer.render(createElement(Harness));
});
assert.equal(document.documentElement.classList.contains('dark'), true);
document.documentElement.className = 'light';
backgrounds.push('application-background');
await act(async () => {
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
});
assert.equal(document.documentElement.classList.contains('dark'), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
+6 -12
View File
@@ -1,6 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -237,11 +237,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
const targetClass = theme.type === "dark" ? "dark" : "light";
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
}
applyTopTabsChromeThemeVars(theme);
@@ -250,11 +248,10 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
if (nextFingerprint === appliedFingerprint) {
return;
}
@@ -284,11 +281,8 @@ export function useActiveChromeTheme({
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
// Settings may have just applied the OS theme over a fixed terminal theme.
// Reassert its visual surfaces even when the terminal fingerprint is stable;
// tab-switch callers retain the default fingerprint fast path.
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
@@ -92,21 +92,3 @@ test("selectPreferredRuntimeAppLockState prefers initialized state when versions
current,
);
});
test("selectPreferredRuntimeAppLockState keeps the current object when a resync changes nothing", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastUnlockedAt: 1000,
lastActivityAt: 2000,
});
const resynced = normalizeRuntimeAppLockState({ ...current });
assert.notEqual(resynced, current);
// Window focus resyncs must not hand React a new state object.
assert.equal(selectPreferredRuntimeAppLockState(current, resynced), current);
const changed = normalizeRuntimeAppLockState({ ...current, locked: true, reason: "idle" });
assert.equal(selectPreferredRuntimeAppLockState(current, changed), changed);
});
+1 -14
View File
@@ -43,20 +43,7 @@ export function selectPreferredRuntimeAppLockState(
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
// Every window focus/visibility change resyncs; keep the current object when
// nothing changed so the gate (and the provider tree under it) does not
// re-render.
return isSameRuntimeAppLockState(current, incoming) ? current : incoming;
}
function isSameRuntimeAppLockState(left: RuntimeAppLockState, right: RuntimeAppLockState): boolean {
return left.initialized === right.initialized
&& left.locked === right.locked
&& left.reason === right.reason
&& left.version === right.version
&& left.lastLockedAt === right.lastLockedAt
&& left.lastUnlockedAt === right.lastUnlockedAt
&& left.lastActivityAt === right.lastActivityAt;
return incoming;
}
export function useAppLockRuntime(
+3 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
normalizeAppLockSettings,
@@ -131,8 +131,6 @@ export function createOptimisticUnlockedRuntimeState(
};
}
export const APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS = 1000;
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
@@ -162,14 +160,8 @@ export function useAppLockState(settings: AppLockSettings) {
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
// Activity only resets a minutes-long idle timer; one report per second is
// enough instead of an IPC per keystroke, wheel tick and pointer event.
const lastActivityReportAtRef = useRef(0);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
const now = Date.now();
if (now - lastActivityReportAtRef.current < APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS) return;
lastActivityReportAtRef.current = now;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
@@ -257,9 +249,7 @@ export function useAppLockState(settings: AppLockSettings) {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
// Stable identity: AppLockGate passes this to <App>, whose provider tree
// re-renders whenever it changes.
return useMemo(() => ({
return {
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
@@ -271,17 +261,5 @@ export function useAppLockState(settings: AppLockSettings) {
reset,
recordActivity,
resync: refreshRuntimeState,
}), [
effectiveRuntimeState.initialized,
effectiveRuntimeState.locked,
effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
refreshRuntimeState,
]);
};
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 144;
export const COMPOSE_BAR_HEIGHT_MIN = 104;
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
@@ -1,106 +0,0 @@
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 { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { refreshObservedTerminalDirectory, registerTerminalDirectory } from './terminalDirectoryRegistry';
import { terminalCwdStore } from './terminalCwdStore';
import { DirectoryPickerError } from '../../domain/directoryPicker';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
test('opening loads children once; search is local; unmount stops chip refresh demand', async () => {
let reads = 0; let probes = 0;
const id = 'hook-directory';
const controller = createTerminalDirectoryController({ sessionId: id,
io: { cwd: async () => { probes++; return '/base'; }, realpath: async path => path, write() {},
list: async () => { reads++; return { home: '/base', entries: [{ name: 'child', path: '/base/child', hidden: false }] }; } },
isCurrent: () => true, isReady: () => true,
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const cleanup = registerTerminalDirectory(id, controller);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
assert.equal(model.cwd, '/base');
await act(async () => { model.setOpen(true); });
assert.equal(reads, 1);
await act(async () => { model.setQuery('CH'); });
assert.equal(model.entries.length, 1);
assert.equal(reads, 1);
await act(async () => { root.unmount(); });
const before = probes;
refreshObservedTerminalDirectory(id);
await Promise.resolve();
assert.equal(probes, before);
cleanup();
});
test('switching terminal cancels an old directory read and closes its popover', async () => {
let finish!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const cleanups = ['hook-a', 'hook-b'].map(id => registerTerminalDirectory(id, createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/' + id, realpath: async path => path, write() {},
list: () => new Promise(resolve => { finish = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
})));
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness({ id }: { id: string }) { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { id: 'hook-a' })); });
await act(async () => { model.setOpen(true); });
await act(async () => { root.update(React.createElement(Harness, { id: 'hook-b' })); });
await act(async () => { finish({ entries: [{ name: 'old', path: '/hook-a/old', hidden: false }], home: null }); });
assert.equal(model.open, false);
assert.equal(model.cwd, '/hook-b');
assert.deepEqual(model.entries, []);
await act(async () => { root.unmount(); });
cleanups.forEach(cleanup => cleanup());
});
test('reconnect after an aborted listing allows manual recovery from a failed cwd probe', async t => {
const id = 'hook-directory-reconnect';
let finishOld!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const oldController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/old', realpath: async value => value, write() {},
list: () => new Promise(resolve => { finishOld = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregisterOld = registerTerminalDirectory(id, oldController);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
let unregisterNew = () => {};
t.after(async () => {
await act(async () => { root.unmount(); });
unregisterOld(); unregisterNew();
});
await act(async () => { root = create(React.createElement(Harness)); });
await act(async () => { model.setOpen(true); });
assert.equal(model.loading, true);
// Close while the request is pending, so the read is already aborted when
// the disconnect disposes its controller and settles the old promise.
await act(async () => { model.setOpen(false); });
await act(async () => { unregisterOld(); terminalCwdStore.setCwd(id, null); });
let failProbe = true;
const nextController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { if (failProbe) throw new DirectoryPickerError('unknown'); return '/recovered'; },
realpath: async value => value, write() {},
list: async () => ({ entries: [{ name: 'new', path: '/recovered/new', hidden: false }], home: null }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
await act(async () => { unregisterNew = registerTerminalDirectory(id, nextController); });
assert.equal(model.cwd, null);
assert.equal(model.error, 'unknown');
assert.equal(model.loading, false, 'cancelled reads must not leave the manual Refresh action disabled');
await act(async () => { failProbe = false; await model.refresh(); model.setOpen(true); });
assert.equal(model.cwd, '/recovered');
assert.equal(model.loading, false);
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
await act(async () => { finishOld({ entries: [{ name: 'stale', path: '/old/stale', hidden: false }], home: null }); });
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
});
@@ -1,94 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { DirectoryPickerError, directoryParent, filterDirectories, type DirectoryEntry, type DirectoryPickerErrorCode } from '../../domain/directoryPicker';
import { terminalCwdStore } from './terminalCwdStore';
import { getTerminalDirectory, observeTerminalDirectory, subscribeTerminalDirectories } from './terminalDirectoryRegistry';
export function useComposeDirectoryPicker(sessionId: string) {
const controller = useSyncExternalStore(subscribeTerminalDirectories, () => getTerminalDirectory(sessionId), () => null);
useSyncExternalStore(terminalCwdStore.subscribe, terminalCwdStore.getVersion, terminalCwdStore.getVersion);
const [confirmed, setConfirmed] = useState(false);
const cwd = controller && (confirmed || controller.hasConfirmedCwd() || terminalCwdStore.getSource(sessionId) === 'osc7')
? terminalCwdStore.getCwd(sessionId) : null;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [home, setHome] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState(false);
const listingBase = useRef<string | null>(null);
const [error, setError] = useState<DirectoryPickerErrorCode | null>(null);
const generation = useRef(0);
const lifetime = useRef(new AbortController());
const read = useRef<AbortController | null>(null);
const sending = useRef(false);
const report = useCallback((failure: unknown, fallback: DirectoryPickerErrorCode) => {
setError(failure instanceof DirectoryPickerError ? failure.code : fallback);
}, []);
useEffect(() => {
const id = ++generation.current;
const stopObserving = observeTerminalDirectory(sessionId);
lifetime.current = new AbortController();
sending.current = false;
setConfirmed(false); setOpen(false); setPending(false); setLoading(false); setHome(null); setError(null); setEntries([]);
listingBase.current = null;
if (controller) void Promise.all([controller.refresh(), controller.home()]).then(([, nextHome]) => {
if (generation.current === id) { setConfirmed(true); setHome(nextHome); }
}).catch(failure => { if (generation.current === id) report(failure, 'unknown'); });
return () => {
generation.current = id + 1;
lifetime.current.abort(); read.current?.abort(); stopObserving();
};
}, [sessionId, controller, report]);
const refresh = useCallback(async () => {
if (!controller) { setError('unavailable'); return; }
const id = generation.current;
setError(null);
try { await controller.refresh(); if (generation.current === id) setConfirmed(true); }
catch (failure) { if (generation.current === id) report(failure, 'unknown'); }
}, [controller, report]);
const load = useCallback(async () => {
if (!controller || !cwd) return;
read.current?.abort();
const task = new AbortController(); read.current = task;
const id = generation.current;
setLoading(true); setEntries([]); setError(null);
try {
const result = await controller.list(cwd, task.signal);
if (!task.signal.aborted && id === generation.current) {
listingBase.current = cwd; setEntries(result.entries); setHome(result.home);
}
} catch (failure) {
if (!task.signal.aborted && id === generation.current) report(failure, 'readFailed');
} finally { if (!task.signal.aborted && id === generation.current) setLoading(false); }
}, [controller, cwd, report]);
useEffect(() => {
if (open) { setQuery(''); void load(); }
return () => { read.current?.abort(); };
}, [open, load]);
const change = useCallback(async (path: string) => {
if (!controller) { setError('unavailable'); return; }
if (sending.current) return;
if (!cwd || terminalCwdStore.getCwd(sessionId) !== cwd
|| (path !== directoryParent(cwd) && listingBase.current !== cwd)) { setError('unavailable'); return; }
sending.current = true; setPending(true); setError(null);
const id = generation.current;
try {
await controller.change(path, lifetime.current.signal, () => {
if (id === generation.current) setOpen(false);
}, cwd ?? undefined);
} catch (failure) { if (id === generation.current) report(failure, 'changeFailed'); }
finally {
if (id === generation.current) { sending.current = false; setPending(false); }
}
}, [controller, report, cwd, sessionId]);
const filtered = useMemo(() => filterDirectories(entries, query, showHidden), [entries, query, showHidden]);
return { cwd, home, open, setOpen, query, setQuery, showHidden, setShowHidden, entries: filtered,
parent: cwd ? directoryParent(cwd) : null, loading, pending, error, available: !!controller, refresh, reload: load, change };
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
subscribeHidden(listener: () => void): () => void; unavailable(): void;
}) {
const contextKey = JSON.stringify(context);
const key = JSON.stringify([viewId, version, slot, contextKey]);
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
const [hidden, setHidden] = useState(false);
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
useEffect(() => {
if (!active) return;
const bridge = netcattyBridge.get();
if (!bridge) { unavailableRef.current(); return; }
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
try {
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
const stop = session.subscribe(update); update(); setHidden(false);
sessionRef.current = { key, send: session.send, image: session.image };
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
} catch { unavailableRef.current(); }
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
const loadImage = useCallback((path: string) => {
const session = sessionRef.current;
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
}, [active, key]);
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
}, [active, key]);
// A newly selected plugin must never receive values from the previous tree,
// even during the render before effect cleanup and the next activation.
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
}
+6 -18
View File
@@ -13,7 +13,6 @@ import { useMonitorPanels } from "./monitorPanelsStorage";
import {
monitorActions,
getMonitorHistoryCursor,
isIdleMonitorSnapshot,
publishMonitorSnapshot,
publishMonitorUnavailable,
} from "./monitorRuntimeStore";
@@ -158,24 +157,16 @@ export function useMonitorRuntime() {
isInitialized,
retryVersion,
]);
// Poll every second while anything is monitored (here or in another window);
// otherwise only every 10 s to notice monitors started elsewhere. Starting a
// panel here publishes demand and reads a snapshot immediately, and flipping
// `hasRunningPanels` restarts this loop at the fast cadence.
const hasRunningPanels = running.size > 0;
useEffect(() => {
const bridge = netcattyBridge.get();
let disposed = false;
let pending = false;
let remoteRunning = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
if (pending || !bridge?.monitorSnapshot) return;
pending = true;
try {
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
if (disposed) return;
remoteRunning = !isIdleMonitorSnapshot(snapshot);
if (
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
(runtimeId.current !== null &&
@@ -195,16 +186,13 @@ export function useMonitorRuntime() {
pending = false;
}
};
const schedule = () => {
if (disposed) return;
timer = setTimeout(() => {
void poll().finally(schedule);
}, hasRunningPanels || remoteRunning ? 1000 : 10_000);
};
void poll().finally(schedule);
void poll();
const timer = setInterval(() => {
void poll();
}, 1000);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
clearInterval(timer);
};
}, [hasRunningPanels]);
}, []);
}
+30 -50
View File
@@ -3,7 +3,8 @@ import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import type { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -26,47 +27,30 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
// The runtime pulls in the AI SDK providers (~1 MB with the Catty
// harness). This host is mounted for every main window, so create it on
// the first plugin AI request instead of at startup.
type HostRuntime = ReturnType<typeof createTrustedPluginAIHostRuntime>;
let runtime: HostRuntime | null = null;
let runtimeLoading: Promise<HostRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: HostRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= Promise.all([
import('../../infrastructure/ai/plugin/trustedHostRuntime'),
import('../../infrastructure/ai/plugin/configuredModel'),
]).then(([{ createTrustedPluginAIHostRuntime: createRuntime }, { getConfiguredPluginModel, prepareConfiguredPluginModel }]) => {
runtime = createRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
@@ -76,28 +60,24 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId));
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin AI request is no longer active');
return loaded.handle(request.kind, request.payload, request.runtimeId, controller.signal);
}).then(
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const pending of requests.values()) pending.controller.abort(); };
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
+33 -53
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import type { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
@@ -22,78 +22,58 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
// The agent runtime pulls in the whole Catty harness and AI SDK. This host
// is mounted for every main window, so create it on the first plugin agent
// request instead of at startup; until then there is nothing to cancel,
// release or invalidate.
type AgentRuntime = ReturnType<typeof createTrustedPluginAgentRuntime>;
let runtime: AgentRuntime | null = null;
let runtimeLoading: Promise<AgentRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: AgentRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= import('../../infrastructure/ai/pluginAgent/trustedAgentRuntime')
.then(({ createTrustedPluginAgentRuntime: createRuntime }) => {
runtime = createRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
});
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
withLoadedRuntime((loaded) => loaded.cancelRequest(request.payload.requestId, request.runtimeId)); return;
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId)); return;
runtime.releaseRuntime(request.runtimeId); return;
}
if (request.kind === 'agent.close') { withLoadedRuntime((loaded) => loaded.releaseChat(request.payload.chatSessionId, request.runtimeId)); return; }
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin agent request is no longer active');
return loaded.handle(request, controller.signal);
}).then(
void runtime.handle(request, controller.signal).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const item of requests.values()) item.controller.abort(); };
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) {
withLoadedRuntime((loaded) => loaded.invalidateConfiguration());
runtime.invalidateConfiguration();
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
@@ -105,7 +85,7 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
void bridge.ready(false).catch(() => {});
};
}, []);
@@ -86,8 +86,14 @@ export interface UsePluginContributionsResult {
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -183,6 +189,32 @@ export function usePluginContributions(
return picker(title, defaultPath);
}, [bridge]);
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
return bridge.openPluginView(payload);
}, [bridge]);
const closeView = useCallback(async (instanceId: string) => {
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (!bridge?.setPluginViewBounds) return;
await bridge.setPluginViewBounds(instanceId, bounds);
}, [bridge]);
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
if (!bridge?.setPluginViewVisibility) return;
await bridge.setPluginViewVisibility(instanceId, visible);
}, [bridge]);
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
if (!bridge?.setPluginEnvironment) return;
await bridge.setPluginEnvironment(environment);
@@ -191,6 +223,10 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
loadedQueryKey: loadedSnapshot.queryKey,
@@ -209,7 +245,13 @@ export function usePluginContributions(
updateSetting,
resetSetting,
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
@@ -1,59 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const token = useRef<string | null>(null);
const active = useRef(false);
const mounted = useRef(true);
const reset = useCallback(() => {
const previous = token.current;
token.current = null;
setPreview(null);
setError(null);
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
};
}, []);
const run = async (operation: () => Promise<void>) => {
if (active.current) return;
active.current = true;
setBusy(true);
setError(null);
try { await operation(); }
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
finally { active.current = false; if (mounted.current) setBusy(false); }
};
return {
preview, busy, error, reset,
prepare: (input: string) => run(async () => {
const { origin } = parsePluginGitHubRepository(input);
const bridge = netcattyBridge.require();
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
reset();
const result = await bridge.prepareGitHubPlugin(origin);
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
token.current = result.token;
setPreview(result);
}),
install: () => run(async () => {
const bridge = netcattyBridge.require();
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
const selected = token.current;
// The host consumes the selection even on failure; retry starts with a new review.
token.current = null;
setPreview(null);
await bridge.installGitHubPlugin(selected);
await onInstalled();
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
}),
};
}
+4 -6
View File
@@ -38,20 +38,18 @@ export function usePluginManager() {
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
let failure: Error | null = null;
try { await action(netcattyBridge.require()); }
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
if (throwOnError && failure) throw failure;
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
@@ -72,6 +70,6 @@ export function usePluginManager() {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}, true),
}),
};
}
@@ -1,22 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { usePluginContributions } from './usePluginContributions';
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
export function usePluginSettingsModel(pluginId: string) {
const { locale } = useI18n();
const catalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
}, [catalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
}), [locale, scopeIds]);
const contributions = usePluginContributions(query);
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
}
+209 -9
View File
@@ -14,12 +14,19 @@ import {
usePluginViewTabs,
} from './pluginViewTabStore';
import {
PluginViewLifecycleController,
reconcilePluginViewTabCatalog,
resolvePluginViewSnapshotSelection,
type HostedPluginViewState,
type PluginViewSnapshotSelection,
} from './pluginViewLifecycle';
import { pluginViewSessions } from './pluginViewSession';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -63,8 +70,17 @@ export function usePluginViewLifecycle({
const {
snapshot,
executeCommand,
openView,
closeView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
} = contributions;
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
const closeViewRef = useRef(closeView);
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
@@ -77,6 +93,16 @@ export function usePluginViewLifecycle({
requestedViewId: effectiveRequested?.viewId,
contextKey: viewQueryContextKey,
});
const activeViewId = activeView?.view.id;
const viewScopeId = typeof window === 'undefined'
? 'window:server'
: resolvePluginViewWindowScope(window.location);
const retainedViewKey = activeViewId
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
: null;
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
useEffect(() => {
if (viewContributions.loading) return;
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
@@ -88,6 +114,26 @@ export function usePluginViewLifecycle({
: null;
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
useEffect(() => onViewClosed((event) => {
const next = lifecycleRef.current.handleHostClose(event.instanceId);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
}), [onViewClosed]);
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
const next = lifecycleRef.current.handleTabClose(tab.id);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
for (const instanceId of next.instanceIds) {
void closeViewRef.current(instanceId).catch(() => {});
}
}), []);
useEffect(() => {
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
.filter((binding) => binding.enabled)
@@ -136,16 +182,164 @@ export function usePluginViewLifecycle({
setRequested(null);
}, [activeView, requested]);
useEffect(() => {
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
return () => { stop(); };
}, []);
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
if (!current.retainContextWhenHidden) {
await closeView(current.id);
return;
}
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
lifecycleRef.current.retain(key, current);
try {
await setViewVisibility(current.id, false);
} catch {
lifecycleRef.current.removeRetained(key);
await closeView(current.id);
}
}, [closeView, setViewVisibility]);
const close = useCallback(async () => {
lifecycleRef.current.markViewClosed(retainedViewKey);
const current = lifecycleRef.current.takeCurrent();
setInstance(null);
setRequested(null);
if (activeView) pluginViewSessions.closeView(activeView.view.id);
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
}, [activePluginTab, activeView]);
if (current) await closeView(current.id);
}, [closeView, retainedViewKey]);
useEffect(() => {
const removed = lifecycleRef.current.removeRetainedWhere(
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
);
for (const retained of removed) void closeView(retained.id).catch(() => {});
}, [closeView, viewScopeId]);
useEffect(() => {
if (!instance) return;
if (effectiveRequested?.viewId === instance.viewId
&& activeViewId === instance.viewId
&& instance.scopeId === viewScopeId) return;
lifecycleRef.current.takeCurrent();
setInstance(null);
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
void hideOrClose(instance);
} else {
void closeView(instance.id).catch(() => {});
}
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
useEffect(() => {
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
let cancelled = false;
const openingTabId = activePluginTab?.id;
const openingToken = lifecycleRef.current.beginOpen({
viewKey: retainedViewKey,
tabId: openingTabId,
label: activePluginTab?.id ?? activeViewId,
});
const bounds = mountRef.current.getBoundingClientRect();
const nextBounds = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
void (async () => {
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
if (opened) {
try {
await setViewBounds(opened.id, nextBounds);
await setViewVisibility(opened.id, true);
} catch {
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
opened = null;
}
}
if (!opened) {
const result = await openView({
viewId: activeViewId,
scopeId: viewScopeId,
bounds: nextBounds,
context: effectiveRequested?.context,
});
opened = {
id: result.instanceId,
viewId: activeViewId,
scopeId: viewScopeId,
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
};
}
if (lifecycleRef.current.consumeHostClose(opened.id)) {
throw new Error('Plugin view closed while its open response was in flight');
}
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
await closeView(opened.id);
return;
}
if (cancelled) {
await hideOrClose(opened);
return;
}
lifecycleRef.current.setCurrent(opened);
setInstance(opened);
})().catch(() => {
if (cancelled) return;
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
else setRequested(null);
}).finally(() => {
lifecycleRef.current.finishOpen({
token: openingToken,
viewKey: retainedViewKey,
tabId: openingTabId,
});
});
return () => { cancelled = true; };
}, [
activePluginTab,
activeView?.view.retainContextWhenHidden,
activeViewId,
closeView,
effectiveRequested?.context,
hideOrClose,
instance,
openView,
retainedViewKey,
setViewBounds,
setViewVisibility,
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
const bounds = mountRef.current?.getBoundingClientRect();
if (!bounds) return;
void setViewBounds(instance.id, {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
});
};
const observer = new ResizeObserver(update);
observer.observe(mountRef.current);
window.addEventListener('resize', update);
update();
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [instance, setViewBounds]);
useEffect(() => {
if (!contributions.available) return;
@@ -182,10 +376,16 @@ export function usePluginViewLifecycle({
};
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
useEffect(() => () => {
const views = lifecycleRef.current.drain();
const ids = new Set(views.map((view) => view.id));
for (const id of ids) void closeViewRef.current(id);
}, []);
return {
activeView,
close,
effectiveRequested,
viewQueryContext,
mountRef,
};
}
+23 -33
View File
@@ -21,7 +21,6 @@ getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -250,8 +249,6 @@ export const useSessionState = ({
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const logViewsRef = useRef(logViews);
logViewsRef.current = logViews;
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
const sessionsRef = useRef(sessions);
const tabOrderRef = useRef(tabOrder);
@@ -1100,10 +1097,10 @@ export const useSessionState = ({
// Set the focused session in a workspace (for focus mode)
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
// Pane click and focus both land here; an unchanged focus must keep the
// same array or every click re-renders the whole shell (and re-saves the
// session restore snapshot).
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: sessionId };
}));
}, []);
const reorderWorkspaceSessions = useCallback((
@@ -1129,7 +1126,7 @@ export const useSessionState = ({
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspacesRef.current.find(w => w.id === workspaceId);
const workspace = workspaces.find(w => w.id === workspaceId);
if (!workspace) {
return false;
}
@@ -1150,10 +1147,13 @@ export const useSessionState = ({
}
// Update focused session
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, nextSessionId));
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: nextSessionId };
}));
return true;
}, []);
}, [workspaces]);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
@@ -1196,10 +1196,6 @@ export const useSessionState = ({
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
// Read by action callbacks at call time so they keep a stable identity:
// orphanSessions changes with every session status update.
const orphanSessionsRef = useRef(orphanSessions);
orphanSessionsRef.current = orphanSessions;
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
@@ -1234,13 +1230,6 @@ export const useSessionState = ({
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
@@ -1261,13 +1250,17 @@ export const useSessionState = ({
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Copy a whole workspace (split/focus tab): clone every pane's session and
// reproduce the layout tree in a new tab.
@@ -1275,14 +1268,7 @@ export const useSessionState = ({
localShellType?: TerminalSession['shellType'];
perPaneCwd?: Record<string, string | undefined>;
}) => {
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const sourceWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const sourceWorkspace = workspaces.find(w => w.id === workspaceId);
if (!sourceWorkspace) return;
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
@@ -1320,13 +1306,17 @@ export const useSessionState = ({
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
setActiveTabId(newWorkspaceId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
});
return [...prevSessions, ...built.newSessions];
});
}, [setActiveTabId]);
}, [workspaces, orphanSessions, logViews, setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
@@ -95,6 +95,7 @@ test("merging a workspace tab with a standalone tab reuses the workspace id (iss
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
-9
View File
@@ -1231,15 +1231,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
return () => mql.removeEventListener('change', handler);
}, []);
// Listen for system theme changes from main process (fallback when matchMedia doesn't fire)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSystemThemeChanged) return;
return bridge.onSystemThemeChanged((payload) => {
setSystemPreference(payload.isDark ? 'dark' : 'light');
});
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
+9 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
@@ -70,55 +70,26 @@ export function shouldReleaseShortcutModifier(
return !isShortcutModifierHeld(event, requirements);
}
// The held flag lives in a tiny store so only the tab icon slots re-render when
// it flips. It used to be TopTabs state, which re-rendered the whole tab bar
// twice for every Ctrl/⌘ shortcut typed in a terminal (Ctrl+C, ⌘V, ...).
let modifierHeld = false;
const modifierHeldListeners = new Set<() => void>();
function publishModifierHeld(next: boolean): void {
if (modifierHeld === next) return;
modifierHeld = next;
for (const listener of modifierHeldListeners) listener();
}
function subscribeModifierHeld(listener: () => void): () => void {
modifierHeldListeners.add(listener);
return () => {
modifierHeldListeners.delete(listener);
};
}
const getModifierHeld = () => modifierHeld;
/** Whether the tab-switch modifier combination is currently held. */
export function useShortcutModifierHeldValue(): boolean {
return useSyncExternalStore(subscribeModifierHeld, getModifierHeld, getModifierHeld);
}
/**
* Track the modifiers of the effective [1...9] tab-switch binding and publish
* whether they are held. Pass a null binding to stop tracking.
*/
export function useTrackShortcutModifierHeld(
export function useShortcutModifierHeld(
keyBinding: string | null,
scheme: HotkeyScheme,
): void {
): boolean {
const [isHeld, setIsHeld] = useState(false);
const requirements = useMemo(
() => getShortcutModifierRequirements(keyBinding, scheme),
[keyBinding, scheme],
);
useEffect(() => {
publishModifierHeld(false);
setIsHeld(false);
if (!requirements) return;
// Sync on every modifier transition so extras clear the preview and
// releasing an extra modifier can re-arm when the exact combo remains.
const syncHeldState = (event: KeyboardEvent) => {
publishModifierHeld(isShortcutModifierHeld(event, requirements));
setIsHeld(isShortcutModifierHeld(event, requirements));
};
const clearHeldState = () => publishModifierHeld(false);
const clearHeldState = () => setIsHeld(false);
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
@@ -135,7 +106,8 @@ export function useTrackShortcutModifierHeld(
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
publishModifierHeld(false);
};
}, [requirements]);
return isHeld;
}
Loaded 100 of 539 files, more files were not shown because too many files have changed in this diff. Show more