mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42e870e74 | ||
|
|
24c2e6beff | ||
|
|
c59ea270e2 | ||
|
|
9b29c115df | ||
|
|
4eceeda4b7 | ||
|
|
c357d14181 | ||
|
|
e7ade527ee | ||
|
|
54c4db5030 | ||
|
|
e17d7f01b6 | ||
|
|
5098f53944 | ||
|
|
c1df8a6011 | ||
|
|
a8bb5a43fc | ||
|
|
b87360783a | ||
|
|
dc5fcfbc19 | ||
|
|
fcfba34fc1 | ||
|
|
fa554888a6 | ||
|
|
d56150953d | ||
|
|
1b9c744fda | ||
|
|
2318434ac9 | ||
|
|
dcf558e685 | ||
|
|
7683874a4e | ||
|
|
3ac24f7aa2 | ||
|
|
79a6e14f7d | ||
|
|
8043178d54 | ||
|
|
9fb2a5c8c9 | ||
|
|
70ce1258f9 | ||
|
|
c01d057a7d | ||
|
|
cd6e1c6990 | ||
|
|
42c69902e8 | ||
|
|
780d297591 | ||
|
|
b496b00d2f | ||
|
|
a8c69b8e11 | ||
|
|
6d687d570f | ||
|
|
a2f23054df | ||
|
|
0fe069e575 | ||
|
|
1809b7dc02 | ||
|
|
d5a03606b7 | ||
|
|
7cd57adcea |
No files matched your search
@@ -170,3 +170,6 @@ build_with_vs2022.bat
|
||||
/resources/et/*/et.exe
|
||||
/resources/et/*/et-*-dlls/
|
||||
/resources/et/*/*.dll
|
||||
|
||||
# Local plugin example staging and bundles
|
||||
/examples/plugins/*/.package/
|
||||
@@ -127,6 +127,11 @@ 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.
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ 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 />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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');
|
||||
});
|
||||
@@ -16,6 +16,7 @@ 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>;
|
||||
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
@@ -1137,11 +1138,18 @@ export function handleCreateLocalTerminalImpl(
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
// 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);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
||||
const matchedShell = !explicitShell
|
||||
? discoveredShells.find(s => s.id === terminalSettings.localShell)
|
||||
: undefined;
|
||||
const shellName = explicitShell?.name ?? matchedShell?.name;
|
||||
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
|
||||
const sessionId = createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
@@ -1271,16 +1279,14 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
}
|
||||
|
||||
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
|
||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
|
||||
{
|
||||
if (hasMultipleProtocols(host)) {
|
||||
setProtocolSelectHost(resolveEffectiveHost(host));
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
} else {
|
||||
handleConnectToHost(host);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +139,21 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
||||
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
||||
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { registerAppHandlers } from './appHandlersBridge';
|
||||
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
|
||||
import { publishAppLocalUi } from './appLocalUiStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { useAutoSync } from '../state/useAutoSync';
|
||||
@@ -19,6 +19,8 @@ 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';
|
||||
@@ -59,6 +61,7 @@ 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';
|
||||
@@ -133,7 +136,6 @@ 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
|
||||
@@ -1156,6 +1158,8 @@ 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)),
|
||||
@@ -1524,6 +1528,19 @@ 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.` };
|
||||
@@ -1568,6 +1585,8 @@ export function AppSideEffects() {
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
reconnectSession: reconnectSessionForVaultAgent,
|
||||
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -1944,7 +1963,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, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
||||
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), 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]);
|
||||
@@ -2012,8 +2031,11 @@ 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({
|
||||
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
|
||||
// Vault glue
|
||||
handleAddKnownHost,
|
||||
handleDeleteHost,
|
||||
@@ -2073,15 +2095,13 @@ export function AppSideEffects() {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
handleCancelDeleteHost,
|
||||
handleConfirmDeleteHost,
|
||||
});
|
||||
}));
|
||||
publishAppLocalUi({
|
||||
isQuickSwitcherOpen,
|
||||
isCreateWorkspaceOpen,
|
||||
addToWorkspaceDialog,
|
||||
quickSearch,
|
||||
protocolSelectHost,
|
||||
navigateToSection,
|
||||
deepLinkHostDraft,
|
||||
@@ -2147,7 +2167,6 @@ export function AppSideEffects() {
|
||||
isQuickSwitcherOpen,
|
||||
isCreateWorkspaceOpen,
|
||||
addToWorkspaceDialog,
|
||||
quickSearch,
|
||||
protocolSelectHost,
|
||||
navigateToSection,
|
||||
deepLinkHostDraft,
|
||||
|
||||
+51
-54
@@ -46,6 +46,7 @@ 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,
|
||||
@@ -53,9 +54,6 @@ 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 })),
|
||||
);
|
||||
@@ -63,6 +61,9 @@ 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();
|
||||
@@ -242,12 +243,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
} = ctx;
|
||||
|
||||
const dismissTransientOverlays = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setIsCreateWorkspaceOpen(false);
|
||||
setProtocolSelectHost(null);
|
||||
setAddToWorkspaceDialog(null);
|
||||
@@ -260,7 +259,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
]);
|
||||
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
@@ -277,7 +275,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, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, 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,
|
||||
@@ -506,6 +504,16 @@ 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;
|
||||
@@ -583,7 +591,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
onFocusEditorWindowTab={focusEditorWindowTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -847,8 +855,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
onRequestClose={requestCloseEditorTabFromView}
|
||||
onPopOut={popOutEditorTabFromView}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
@@ -937,50 +945,39 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RenameDraftDialog
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -51,6 +51,37 @@ 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);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export type AppLocalUiSnapshot = {
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null;
|
||||
quickSearch: string;
|
||||
protocolSelectHost: Host | null;
|
||||
navigateToSection: VaultSection | null;
|
||||
deepLinkHostDraft: Host | null;
|
||||
@@ -43,7 +42,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
isQuickSwitcherOpen: false,
|
||||
isCreateWorkspaceOpen: false,
|
||||
addToWorkspaceDialog: null,
|
||||
quickSearch: '',
|
||||
protocolSelectHost: null,
|
||||
navigateToSection: null,
|
||||
deepLinkHostDraft: null,
|
||||
@@ -76,7 +74,6 @@ 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
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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,
|
||||
@@ -11,16 +8,14 @@ import {
|
||||
} from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
|
||||
const EMPTY_HOST_RESULTS: Host[] = [];
|
||||
|
||||
/**
|
||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
||||
* selective vault hosts subscription for quick-search results when open.
|
||||
* 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.
|
||||
* 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,
|
||||
@@ -28,22 +23,6 @@ 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 {
|
||||
@@ -68,14 +47,11 @@ 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,
|
||||
@@ -88,8 +64,6 @@ export function DialogsHost() {
|
||||
local.passphraseQueue,
|
||||
local.fidoPromptQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
]);
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
|
||||
@@ -14,7 +14,22 @@ 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,
|
||||
});
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
// 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;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
@@ -32,6 +47,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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'));
|
||||
});
|
||||
@@ -293,7 +293,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -316,17 +319,21 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
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;
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
@@ -334,7 +341,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
...rule,
|
||||
canStop: hasRuntimeTunnel(rule.id),
|
||||
})),
|
||||
hosts: hostsForSystemMenu,
|
||||
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
|
||||
});
|
||||
}, 250);
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { MESSAGES_BY_LOCALE } from './messages';
|
||||
import {
|
||||
getBaseFallbackLocale,
|
||||
getLoadedLocaleMessages,
|
||||
getLocaleMessagesVersion,
|
||||
loadLocaleMessages,
|
||||
subscribeLocaleMessages,
|
||||
type Messages,
|
||||
} from './messages';
|
||||
|
||||
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
@@ -29,32 +36,59 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
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];
|
||||
type LocaleDictionaries = {
|
||||
direct?: Messages;
|
||||
base?: Messages;
|
||||
fallback?: Messages;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(resolvedLocale, key) ?? key;
|
||||
const msg = resolveMessage(dictionaries, key) ?? key;
|
||||
return interpolate(msg, values);
|
||||
},
|
||||
};
|
||||
}, [locale, resolvedLocale]);
|
||||
}, [dictionaries, locale, resolvedLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1401,6 +1401,7 @@ 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.',
|
||||
|
||||
@@ -460,6 +460,7 @@ 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',
|
||||
@@ -474,6 +475,7 @@ 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.',
|
||||
|
||||
@@ -1401,6 +1401,7 @@ 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.',
|
||||
|
||||
@@ -460,6 +460,7 @@ 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',
|
||||
@@ -474,6 +475,7 @@ 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.',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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');
|
||||
});
|
||||
@@ -1266,6 +1266,7 @@ 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': 'Включённые плагины не предоставляют настройки.',
|
||||
|
||||
@@ -478,6 +478,7 @@ 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': 'Макс. число копий',
|
||||
@@ -492,6 +493,7 @@ 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': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
||||
|
||||
@@ -1316,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.configuration": "插件设置",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
|
||||
@@ -888,6 +888,7 @@ 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': '最多保留',
|
||||
@@ -902,6 +903,7 @@ 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': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
|
||||
@@ -1287,6 +1287,7 @@ 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} 個插件",
|
||||
|
||||
@@ -888,6 +888,7 @@ 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': '最多保留',
|
||||
@@ -902,6 +903,7 @@ 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': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
|
||||
|
||||
@@ -1,17 +1,78 @@
|
||||
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 another import and map entry.
|
||||
// Keep keys stable; add new locales by adding a loader below and an entry in
|
||||
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
|
||||
export { type Messages };
|
||||
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
es,
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
/**
|
||||
* 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'),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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 };
|
||||
}
|
||||
@@ -18,28 +18,34 @@ 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 {
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return !parseSessionsStore(raw).valid;
|
||||
}
|
||||
|
||||
/** 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[] {
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return parseSessionsStore(raw).sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import {
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
@@ -17,6 +23,11 @@ import {
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
rebindActiveSessionMap,
|
||||
readSessionsFromStorage,
|
||||
prewarmAIStateStorageSnapshots,
|
||||
writeSessionsForStorage,
|
||||
flushBackupMirror,
|
||||
__resetBackupMirrorForTests,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -73,6 +84,424 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
function installSessionStorage(t: TestContext, aggregateQuota = Number.POSITIVE_INFINITY) {
|
||||
const storage = new Map<string, string>();
|
||||
const previousStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
const storedBytes = [...storage].reduce((total, [storedKey, storedValue]) => (
|
||||
total + (storedKey === key ? 0 : Buffer.byteLength(storedKey + storedValue))
|
||||
), 0);
|
||||
if (storedBytes + Buffer.byteLength(key + value) > aggregateQuota) {
|
||||
throw new DOMException('aggregate quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
},
|
||||
});
|
||||
__resetBackupMirrorForTests();
|
||||
t.after(() => {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: previousStorage });
|
||||
});
|
||||
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: false }));
|
||||
return storage;
|
||||
}
|
||||
|
||||
function makeLargeSessions(count = 4) {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`large-${index}`, count - index,
|
||||
Array.from({ length: 200 }, (_, messageIndex) => ({
|
||||
id: `message-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'x'.repeat(5 * 1024 + 128),
|
||||
timestamp: messageIndex,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
test('local-only writes retain four full large chats across overflow chunks and prewarming', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const sessions = freezeHistory(makeLargeSessions());
|
||||
assert.ok(JSON.stringify(sessions).length > 2 * 1024 * 1024);
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
const primary = JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
|
||||
assert.ok(primary.length < sessions.length);
|
||||
assert.ok(storage.has(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`));
|
||||
for (const [key, value] of storage) {
|
||||
if (key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW) || key === STORAGE_KEY_AI_SESSIONS) {
|
||||
assert.ok(value.length <= 2 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
// A later write of the reloaded history must retain the overflow again.
|
||||
assert.equal(writeSessionsForStorage(readSessionsFromStorage()), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
try {
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
prewarmAIStateStorageSnapshots();
|
||||
assert.deepEqual(latestAISessionsSnapshot, sessions);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions as AISession[]);
|
||||
}
|
||||
});
|
||||
|
||||
test('backup includes overflow chats before the primary blob is replaced', (t) => {
|
||||
installSessionStorage(t);
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
} } },
|
||||
});
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
|
||||
const sessions = makeLargeSessions();
|
||||
const setItem = globalThis.localStorage.setItem;
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
if (key === STORAGE_KEY_AI_SESSIONS) {
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backupWrites[0]), sessions);
|
||||
}
|
||||
setItem(key, value);
|
||||
});
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
const primary = JSON.parse(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
|
||||
const primaryIds = new Set(primary.map(session => session.id));
|
||||
assert.ok(sessions.some(session => !primaryIds.has(session.id)));
|
||||
});
|
||||
|
||||
test('shrinking local history removes stale overflow chunks and deleted chats', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const sessions = makeLargeSessions();
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writeSessionsForStorage(sessions.slice(0, 2)), true);
|
||||
assert.ok(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW));
|
||||
assert.equal(storage.has(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), false);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions.slice(0, 2));
|
||||
assert.equal(writeSessionsForStorage([sessions[0]]), true);
|
||||
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
|
||||
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
|
||||
assert.equal(writeSessionsForStorage([]), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), []);
|
||||
});
|
||||
|
||||
test('merged storage deduplicates primary and overflow by the newer updatedAt', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const primary = [makeSession('primary-newer', 3, []), makeSession('overflow-newer', 1, [])];
|
||||
const overflow = [makeSession('primary-newer', 2, []), makeSession('overflow-newer', 4, [])];
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify(primary));
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify(overflow));
|
||||
assert.deepEqual(readSessionsFromStorage(), [overflow[1], primary[0]]);
|
||||
});
|
||||
|
||||
test('the 50-session count cap still applies to primary plus overflow', (t) => {
|
||||
installSessionStorage(t);
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`session-${index}`, index, [{
|
||||
id: 'm', role: 'user', content: 'x'.repeat(50 * 1024), timestamp: 0,
|
||||
}]));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
const loaded = readSessionsFromStorage();
|
||||
assert.equal(loaded.length, 50);
|
||||
assert.deepEqual(loaded.map(session => session.id), sessions.slice(1).reverse().map(session => session.id));
|
||||
});
|
||||
|
||||
test('cloud sync restores overflow if its full authoritative write fails', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
assert.equal(writeSessionsForStorage(makeLargeSessions()), true);
|
||||
const originalOverflow = storage.get(STORAGE_KEY_AI_SESSIONS_OVERFLOW);
|
||||
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
|
||||
const before = new Map(storage);
|
||||
const sessions = [makeSession('synced', 5, [])];
|
||||
const writeString = localStorageAdapter.writeString;
|
||||
const write = t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => (
|
||||
key === STORAGE_KEY_AI_SESSIONS ? false : writeString(key, value)
|
||||
));
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(storage.get(STORAGE_KEY_AI_SESSIONS_OVERFLOW), originalOverflow);
|
||||
assert.deepEqual(storage, before);
|
||||
const error = new Error('unexpected primary write failure');
|
||||
write.mock.mockImplementation((key: string, value: string) => {
|
||||
if (key === STORAGE_KEY_AI_SESSIONS) throw error;
|
||||
return writeString(key, value);
|
||||
});
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.deepEqual(storage, before);
|
||||
write.mock.restore();
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
});
|
||||
|
||||
test('enabling cloud sync frees overflow quota for the authoritative primary', (t) => {
|
||||
const storage = installSessionStorage(t, 5 * 1024 * 1024);
|
||||
const sessions = freezeHistory(makeLargeSessions());
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW));
|
||||
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), sessions);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
});
|
||||
|
||||
test('aggregate quota retries shed replay bulk from overflow and retain every chat', (t) => {
|
||||
const storage = installSessionStorage(t, 5 * 1024 * 1024);
|
||||
const sessions = freezeHistory(Array.from({ length: 6 }, (_, index) => makeSession(
|
||||
`bulk-${index}`, 6 - index, [{
|
||||
id: `message-${index}`,
|
||||
role: 'assistant',
|
||||
content: 'x'.repeat(700 * 1024),
|
||||
timestamp: index,
|
||||
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(300 * 1024) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: {
|
||||
reasoningEncryptedContent: 'c'.repeat(100 * 1024),
|
||||
} } }],
|
||||
},
|
||||
}],
|
||||
)));
|
||||
assert.ok(JSON.stringify(sessions).length > 5 * 1024 * 1024);
|
||||
const writes = t.mock.method(localStorageAdapter, 'writeString');
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes.mock.calls.some(call => call.result === false));
|
||||
const primary = JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
|
||||
assert.equal(primary[0].id, sessions[0].id);
|
||||
const overflow = JSON.parse([...storage]
|
||||
.filter(([key]) => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW))
|
||||
.map(([, value]) => value).join('')) as AISession[];
|
||||
assert.ok(overflow.length > 0);
|
||||
assert.ok(overflow.every(session => {
|
||||
const message = session.messages[0];
|
||||
return (message.toolResults?.[0].content.length ?? 0) <= 8 * 1024
|
||||
&& !JSON.stringify(message.providerContinuation).includes('reasoningEncryptedContent');
|
||||
}));
|
||||
const loaded = readSessionsFromStorage();
|
||||
assert.deepEqual(loaded.map(session => session.id), sessions.map(session => session.id));
|
||||
assert.deepEqual(loaded.map(session => session.messages[0].content), sessions.map(session => session.messages[0].content));
|
||||
});
|
||||
|
||||
test('a smaller primary reclaims quota before overflow when the final history fits', (t) => {
|
||||
const storage = installSessionStorage(t, 5 * 1024 * 1024);
|
||||
const chat = (id: string, updatedAt: number, mib: number) => makeSession(id, updatedAt, [{
|
||||
id: 'm', role: 'user', content: 'x'.repeat(Math.floor(mib * 1024 * 1024)), timestamp: 0,
|
||||
}]);
|
||||
const previous = [chat('old-primary', 2, 1.9), chat('old-overflow', 1, 1.5)];
|
||||
assert.equal(writeSessionsForStorage(previous), true);
|
||||
const next = [chat('new-primary', 3, 1.1), ...previous];
|
||||
assert.equal(writeSessionsForStorage(next), true);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), [next[0]]);
|
||||
assert.deepEqual(readSessionsFromStorage(), next);
|
||||
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
|
||||
});
|
||||
|
||||
test('overflow failure rolls back a replaced primary and the previous overflow', (t) => {
|
||||
const storage = installSessionStorage(t, 5 * 1024 * 1024);
|
||||
const previous = makeLargeSessions();
|
||||
assert.equal(writeSessionsForStorage(previous), true);
|
||||
const before = new Map(storage);
|
||||
const writeString = localStorageAdapter.writeString;
|
||||
const error = new Error('overflow write failed');
|
||||
let failOnce = true;
|
||||
t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => {
|
||||
if (failOnce && key === STORAGE_KEY_AI_SESSIONS_OVERFLOW) {
|
||||
failOnce = false;
|
||||
assert.notEqual(storage.get(STORAGE_KEY_AI_SESSIONS), before.get(STORAGE_KEY_AI_SESSIONS));
|
||||
throw error;
|
||||
}
|
||||
return writeString(key, value);
|
||||
});
|
||||
assert.throws(() => writeSessionsForStorage([
|
||||
{ ...previous[0], title: 'New primary' }, ...previous.slice(1),
|
||||
]), candidate => candidate === error);
|
||||
assert.deepEqual(storage, before);
|
||||
});
|
||||
|
||||
test('aggregate quota falls back to a current primary with full backup and persists later turns', (t) => {
|
||||
const storage = installSessionStorage(t, 5 * 1024 * 1024);
|
||||
assert.equal(writeSessionsForStorage([makeSession('previous-primary', 0, [])]), true);
|
||||
__resetBackupMirrorForTests();
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
} } },
|
||||
});
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
|
||||
const sessions = freezeHistory(makeLargeSessions(6));
|
||||
assert.ok(JSON.stringify(sessions).length > 5 * 1024 * 1024);
|
||||
const setItem = globalThis.localStorage.setItem;
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
if (key === STORAGE_KEY_AI_SESSIONS) {
|
||||
// The full intended history must be scheduled before replacing the old
|
||||
// valid primary, including on the subsequent mutation of the same chat.
|
||||
flushBackupMirror();
|
||||
assert.equal((JSON.parse(backupWrites.at(-1)!) as AISession[]).length, 6);
|
||||
}
|
||||
setItem(key, value);
|
||||
});
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backupWrites.at(-1)!), sessions);
|
||||
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), sessions.slice(1).map(session => session.id));
|
||||
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
|
||||
|
||||
const updated = sessions.map((session, index) => index === 0 ? {
|
||||
...session,
|
||||
updatedAt: session.updatedAt + 1,
|
||||
messages: [...session.messages.slice(1), {
|
||||
id: 'new-turn', role: 'user' as const, content: 'Latest turn survives reload', timestamp: 1000,
|
||||
}],
|
||||
} : session);
|
||||
assert.equal(writeSessionsForStorage(updated), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backupWrites.at(-1)!), updated);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), [updated[0]]);
|
||||
assert.deepEqual(readSessionsFromStorage(), [updated[0]]);
|
||||
});
|
||||
|
||||
test('live writes resolve only the disk-only IDs included in primary or overflow', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const sessions = makeLargeSessions();
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify([sessions[0]]));
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([
|
||||
sessions[0].id, sessions[1].id, 'unrecovered', 'deleted',
|
||||
]));
|
||||
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['deleted'] }), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ['unrecovered']);
|
||||
|
||||
// Reusing the same immutable array must still process explicit deletion,
|
||||
// including when the primary itself has not changed.
|
||||
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['unrecovered'] }), true);
|
||||
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
|
||||
});
|
||||
|
||||
test('same-array primary writes preserve pending recovery until explicit deletion or sync', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const sessions = [makeSession('primary', 1, [])];
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ['unrecovered']);
|
||||
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['unrecovered'] }), true);
|
||||
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
|
||||
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
|
||||
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
|
||||
});
|
||||
|
||||
test('tiny newest chat bounds overflow writes under aggregate quota and preserves unresolved IDs', (t) => {
|
||||
const storage = installSessionStorage(t, 512 * 1024);
|
||||
const sessions = [makeSession('empty-newest', 10, []), ...Array.from({ length: 3 }, (_, index) => (
|
||||
makeSession(`huge-older-${index}`, 3 - index, [{
|
||||
id: 'm', role: 'user', content: 'x'.repeat(768 * 1024), timestamp: 0,
|
||||
}])
|
||||
))];
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify([sessions[0]]));
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
|
||||
const writes = t.mock.method(globalThis.localStorage, 'setItem');
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes.mock.callCount() < 150, `bounded writes: ${writes.mock.callCount()}`);
|
||||
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [
|
||||
'unrecovered', ...sessions.slice(1).map(session => session.id),
|
||||
]);
|
||||
});
|
||||
|
||||
test('primary-only fallback frees primary quota before writing recovery metadata', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const previous = [makeSession('old-primary', 1, [{
|
||||
id: 'm', role: 'user', content: 'x'.repeat(64 * 1024), timestamp: 0,
|
||||
}])];
|
||||
const previousJson = JSON.stringify(previous);
|
||||
const sessions = [makeSession('empty-newest', 10, []), ...makeLargeSessions(2)];
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS, previousJson);
|
||||
// The old primary fills the quota. Metadata fits only after it is replaced.
|
||||
const aggregateQuota = [...storage].reduce((total, [key, value]) => total + key.length + value.length, 0);
|
||||
const setItem = globalThis.localStorage.setItem;
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
const total = [...storage].reduce((sum, [storedKey, storedValue]) => (
|
||||
sum + (storedKey === key ? 0 : storedKey.length + storedValue.length)
|
||||
), key.length + value.length);
|
||||
if (total > aggregateQuota) throw new DOMException('aggregate quota exceeded', 'QuotaExceededError');
|
||||
setItem(key, value);
|
||||
});
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
|
||||
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), sessions.slice(1).map(session => session.id));
|
||||
});
|
||||
|
||||
test('primary-only fallback rolls back when recovery metadata cannot be written', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const previous = [makeSession('previous', 0, [])];
|
||||
assert.equal(writeSessionsForStorage(previous), true);
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
|
||||
const before = new Map(storage);
|
||||
const setItem = globalThis.localStorage.setItem;
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
if (key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW) || key === STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
setItem(key, value);
|
||||
});
|
||||
assert.equal(writeSessionsForStorage([makeSession('new', 1, []), ...makeLargeSessions(2)]), false);
|
||||
assert.deepEqual(storage, before);
|
||||
});
|
||||
|
||||
test('unchanged primary does not hide modified overflow from the write cache', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const sessions = freezeHistory(makeLargeSessions());
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
storage.set(STORAGE_KEY_AI_SESSIONS_OVERFLOW, 'corrupt overflow');
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
});
|
||||
|
||||
test('quota failures retain the previous overflow and back up the intended full set', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const original = makeLargeSessions();
|
||||
assert.equal(writeSessionsForStorage(original), true);
|
||||
__resetBackupMirrorForTests();
|
||||
const before = new Map(storage);
|
||||
const next = original.map(session => ({ ...session, title: 'Changed', updatedAt: session.updatedAt + 10 }));
|
||||
t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => {
|
||||
if (key === STORAGE_KEY_AI_SESSIONS) return false;
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
});
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
} } },
|
||||
});
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
|
||||
assert.equal(writeSessionsForStorage(next), false);
|
||||
assert.deepEqual(storage, before);
|
||||
assert.deepEqual(readSessionsFromStorage(), original);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backupWrites[0]), next);
|
||||
});
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
@@ -92,6 +521,7 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'readString', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
@@ -181,6 +611,7 @@ test('orphan cleanup marks dangling chats instead of stripping them, and clears
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'readString', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
@@ -236,6 +667,7 @@ test('orphan cleanup with an empty live-target set marks all scoped chats dangli
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'readString', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
@@ -626,49 +1058,26 @@ test('writeSessionsForStorage retries below nominal budgets after a quota failur
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
test('writeSessionsForStorage splits overflow to fit a sub-512 KB per-key quota', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
storage.set(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes[0].length > 300 * 1024);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`, 10 - index,
|
||||
[{ id: `message-${index}`, role: 'user', content: 'x'.repeat(40 * 1024), timestamp: index }],
|
||||
));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes[0].length > 300 * 1024);
|
||||
const primary = storage.get(STORAGE_KEY_AI_SESSIONS)!;
|
||||
assert.ok(primary.length <= 300 * 1024);
|
||||
assert.ok((JSON.parse(primary) as AISession[]).length < sessions.length);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
|
||||
@@ -780,50 +1189,26 @@ test('writeSessionsForStorage keeps every chat by shedding tool output under quo
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
test('writeSessionsForStorage makes a final attempt with the newest primary and keeps overflow', (t) => {
|
||||
const storage = installSessionStorage(t);
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 50 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 50 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
storage.set(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writes.length, 6);
|
||||
assert.ok(stored);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
|
||||
assert.ok(stored.length <= 50 * 1024);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`, 10 - index,
|
||||
[{ id: `message-${index}`, role: 'user', content: 'x'.repeat(40 * 1024), timestamp: index }],
|
||||
));
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes.some(value => value.length > 50 * 1024));
|
||||
const primary = storage.get(STORAGE_KEY_AI_SESSIONS)!;
|
||||
assert.deepEqual((JSON.parse(primary) as AISession[]).map(session => session.id), ['session-0']);
|
||||
assert.ok(primary.length <= 50 * 1024);
|
||||
assert.deepEqual(readSessionsFromStorage(), sessions);
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
@@ -899,18 +1284,18 @@ test('synced storage bypasses fit-first pruning and non-quota failures still pro
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'readString', () => null);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
// Synced: one write of the complete, unpruned history (serialized once).
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
assert.equal(writeString.mock.calls[0].arguments[1], JSON.stringify(sessions));
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
assert.equal(writeString.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
@@ -945,6 +1330,7 @@ test('successful store writes schedule a throttled disk backup mirror', async (t
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'readString', () => null);
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
// Throttled: nothing has been written to disk yet.
|
||||
@@ -953,11 +1339,12 @@ test('successful store writes schedule a throttled disk backup mirror', async (t
|
||||
assert.equal(backupWrites.length, 1);
|
||||
// The mirror carries exactly the JSON that landed in localStorage.
|
||||
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
|
||||
// Failed store writes never schedule a mirror.
|
||||
// A failed shrinking write still mirrors the full count-capped history.
|
||||
writeString.mock.mockImplementation(() => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
assert.equal(backupWrites.length, 2);
|
||||
assert.deepEqual(JSON.parse(backupWrites[1]), sessions);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
@@ -987,3 +1374,25 @@ test('rebindActiveSessionMap drops stale pointers and sets the new scope', () =>
|
||||
{ 'terminal:new-ws': 'chat-1' },
|
||||
);
|
||||
});
|
||||
|
||||
test('rewriting the same sessions array skips re-serialization while storage still holds it', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
let stored: string | null = null;
|
||||
t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', (_key: string, value: string) => {
|
||||
stored = value;
|
||||
return true;
|
||||
});
|
||||
t.mock.method(localStorageAdapter, 'readString', (key: string) => key === STORAGE_KEY_AI_SESSIONS ? stored : null);
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
// Cloud-sync collection writes the same immutable array again.
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
assert.equal(stringify.mock.callCount(), 0);
|
||||
// Another window replaced the store: write again.
|
||||
stored = '[]';
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writeString.mock.callCount(), 2);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
@@ -23,12 +25,14 @@ import {
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import { DebouncedBackupMirror, mergeImportedSessions, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** 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;
|
||||
@@ -63,8 +67,18 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
|
||||
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) {
|
||||
getAgentRuntime().clearChatSession(sessionId);
|
||||
if (runtime) {
|
||||
runtime.clearChatSession(sessionId);
|
||||
} else {
|
||||
setReversibleModelPrivacy(sessionId, false);
|
||||
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
|
||||
}
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
||||
}
|
||||
@@ -72,8 +86,9 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
|
||||
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
const runtime = peekAgentRuntimeCleanup();
|
||||
for (const terminalSessionId of new Set(terminalSessionIds)) {
|
||||
getAgentRuntime().clearTerminalSession(terminalSessionId);
|
||||
runtime?.clearTerminalSession(terminalSessionId);
|
||||
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -94,7 +109,7 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
?? readSessionsFromStorage();
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -145,7 +160,14 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
// Startup cleanup can run before the disk backup read resolves. Defer
|
||||
// persistence until those chats are merged, preserving their recovery
|
||||
// marker and avoiding a partial backup of only the loaded sessions.
|
||||
const diskOnlyIds = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? [];
|
||||
const loadedIds = new Set(markedSessions.map(session => session.id));
|
||||
if (!diskOnlyIds.some(id => !loadedIds.has(id))) {
|
||||
writeSessionsForStorage(markedSessions);
|
||||
}
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -186,10 +208,9 @@ const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* Byte budget for the serialized sessions JSON. The localStorage quota is
|
||||
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
|
||||
* of KB per turn, so keep the sessions blob well under the quota with
|
||||
* headroom for the rest of the app's storage keys.
|
||||
* String-length budget for the primary sessions JSON and each overflow chunk.
|
||||
* Replay bulk is shed from the primary blob first; whole sessions that still
|
||||
* do not fit are retained in overflow rather than silently evicted.
|
||||
*/
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
@@ -492,16 +513,17 @@ export function serializeSessionsForStorage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Persist sessions to localStorage with byte-budgeted primary/overflow slices.
|
||||
* Returns true when local history was saved, possibly as a newest-primary
|
||||
* fallback with the full history scheduled for disk backup under quota pressure.
|
||||
*/
|
||||
|
||||
// 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.
|
||||
// not take the chat history with it. Persisted history is mirrored, including
|
||||
// overflow sessions, throttled to at most one write per mutation batch.
|
||||
// Before a byte-budget drop, mirror the full
|
||||
// count-capped set even if the subsequent localStorage writes fail.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
@@ -530,14 +552,187 @@ export function scheduleBackupMirror(json: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
/**
|
||||
* Last successful write: the same immutable sessions array is written again by
|
||||
* the debounced save path and by cloud-sync collection, and each write used to
|
||||
* re-serialize the whole (often multi-MB) history.
|
||||
*/
|
||||
let lastSessionsWrite: { sessions: AISession[]; synced: boolean; json: string } | null = null;
|
||||
|
||||
function rememberSessionsWrite(sessions: AISession[], synced: boolean, json: string): void {
|
||||
lastSessionsWrite = { sessions, synced, json };
|
||||
}
|
||||
|
||||
function storedSessionsJsonEquals(json: string): boolean {
|
||||
try {
|
||||
return localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS) === json;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function overflowChunkKey(index: number): string {
|
||||
return index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||
}
|
||||
|
||||
function readOverflowChunks(): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const chunk = localStorageAdapter.readString(overflowChunkKey(index));
|
||||
if (chunk == null) return chunks;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverflowChunks(startIndex = 0) {
|
||||
for (let index = startIndex; localStorageAdapter.readString(overflowChunkKey(index)) != null; index += 1) {
|
||||
localStorageAdapter.remove(overflowChunkKey(index));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskOverflowIds(ids: string[]): boolean {
|
||||
if (ids.length > 0) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, ids);
|
||||
}
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the entire overflow blob; never leave a partially written JSON array. */
|
||||
function replaceOverflowChunks(chunks: string[]): boolean {
|
||||
clearOverflowChunks();
|
||||
let complete = false;
|
||||
try {
|
||||
complete = chunks.every((chunk, index) => (
|
||||
localStorageAdapter.writeString(overflowChunkKey(index), chunk)
|
||||
));
|
||||
return complete;
|
||||
} finally {
|
||||
if (!complete) clearOverflowChunks();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read both local retention slices; newer copies win if a session is duplicated. */
|
||||
export function readSessionsFromStorage(): AISession[] {
|
||||
return mergeImportedSessions(
|
||||
readLiveSessionsArray(readOverflowChunks().join('')),
|
||||
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
options: { deletedSessionIds?: readonly string[] } = {},
|
||||
): boolean {
|
||||
const synced = Boolean(
|
||||
localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled,
|
||||
);
|
||||
// Nothing to do when this exact array was already written in this mode and
|
||||
// storage still holds that write (another window may have replaced it).
|
||||
if (
|
||||
lastSessionsWrite?.sessions === sessions
|
||||
&& lastSessionsWrite.synced === synced
|
||||
&& storedSessionsJsonEquals(lastSessionsWrite.json)
|
||||
// A matching primary alone cannot establish that overflow is intact.
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_OVERFLOW) == null
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) == null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// 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;
|
||||
if (synced) {
|
||||
const json = JSON.stringify(sessions);
|
||||
const previousChunks = readOverflowChunks();
|
||||
// The authoritative primary replaces overflow, so free its quota first.
|
||||
clearOverflowChunks();
|
||||
let syncedOk = false;
|
||||
try {
|
||||
syncedOk = localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, json);
|
||||
if (syncedOk) {
|
||||
clearOverflowChunks();
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
scheduleBackupMirror(json);
|
||||
rememberSessionsWrite(sessions, true, json);
|
||||
}
|
||||
return syncedOk;
|
||||
} finally {
|
||||
if (!syncedOk) replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
const inputIds = new Set(sessions.map(session => session.id));
|
||||
const pruned = pruneSessionsForStorage(sessions);
|
||||
const retainedIds = new Set(pruned.map(session => session.id));
|
||||
const deletedIds = new Set(options.deletedSessionIds);
|
||||
// A live write can race startup's backup read. Preserve marker IDs absent
|
||||
// from the live input unless explicitly deleted. IDs present in the input
|
||||
// are resolved even if the count cap evicts them; pending IDs block mirroring.
|
||||
const diskOverflowIds = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
const unresolvedDiskIds: string[] = Array.isArray(diskOverflowIds)
|
||||
? diskOverflowIds.filter((id): id is string => (
|
||||
typeof id === 'string' && !retainedIds.has(id) && !deletedIds.has(id) && !inputIds.has(id)
|
||||
))
|
||||
: [];
|
||||
let fullBackupScheduled = false;
|
||||
const ensureFullBackup = () => {
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) {
|
||||
scheduleBackupMirror(JSON.stringify(pruned));
|
||||
fullBackupScheduled = true;
|
||||
}
|
||||
};
|
||||
const persistCandidate = (
|
||||
candidate: { json: string; sessions: AISession[] },
|
||||
budget: number,
|
||||
chunkBudget = budget,
|
||||
): boolean => {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const omitted = pruned.filter(session => !primaryIds.has(session.id));
|
||||
if (omitted.length > 0) ensureFullBackup();
|
||||
// Apply the same replay-bulk ladder to every overflow chat as budgets
|
||||
// tighten. Chunking alone cannot reduce an origin's aggregate quota use.
|
||||
const overflow = omitted.map(session => serializeSessionsForStorage([session], budget).sessions[0]);
|
||||
const overflowJson = overflow.length > 0 ? JSON.stringify(overflow) : '';
|
||||
// Split the array's JSON string, so even one oversized session can span
|
||||
// chunks without losing messages. Concatenate all chunks before parsing.
|
||||
const chunks: string[] = [];
|
||||
// Primary budgets can shrink to a tiny empty chat. Keep overflow writes
|
||||
// bounded independently while still supporting small per-key quotas.
|
||||
const chunkSize = Math.max(32 * 1024, chunkBudget);
|
||||
for (let offset = 0; offset < overflowJson.length; offset += chunkSize) {
|
||||
chunks.push(overflowJson.slice(offset, offset + chunkSize));
|
||||
}
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
let primaryWritten = false;
|
||||
let persisted = false;
|
||||
try {
|
||||
// Reclaim both old slices before writing overflow: the final history may
|
||||
// fit even when overflow plus the old, larger primary would exceed quota.
|
||||
clearOverflowChunks();
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return false;
|
||||
primaryWritten = true;
|
||||
if (!replaceOverflowChunks(chunks)) return false;
|
||||
if (!writeDiskOverflowIds(unresolvedDiskIds)) return false;
|
||||
persisted = true;
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) scheduleBackupMirror(candidate.json);
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
} finally {
|
||||
if (!persisted) {
|
||||
clearOverflowChunks();
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
};
|
||||
const primaryCandidates: { json: string; sessions: AISession[] }[] = [];
|
||||
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
|
||||
@@ -549,24 +744,60 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
: 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;
|
||||
}
|
||||
const candidate = serializeSessionsForStorage(pruned, budget);
|
||||
primaryCandidates.push(candidate);
|
||||
// Even an unchanged primary can succeed with smaller overflow chunks or
|
||||
// more degraded overflow replay data at this retry budget.
|
||||
if (persistCandidate(candidate, budget)) return true;
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
// produce (the newest session with replay-only ciphertext removed). A very
|
||||
// small amount of shared quota may still be enough to preserve that chat.
|
||||
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
|
||||
if (
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
// Last resort: use the smallest primary representation (the newest chat
|
||||
// with replay-only bulk removed), retaining other chats in overflow.
|
||||
const minimalCandidate = serializeSessionsForStorage(pruned, 0);
|
||||
const minimalChunkBudget = Math.min(minimalCandidate.json.length, RETRY_SESSIONS_JSON_BYTES[RETRY_SESSIONS_JSON_BYTES.length - 1]);
|
||||
primaryCandidates.push(minimalCandidate);
|
||||
if (persistCandidate(minimalCandidate, 0, minimalChunkBudget)) return true;
|
||||
|
||||
// If the complete retained set cannot fit, save the newest primary anyway.
|
||||
// Schedule the full disk mirror before discarding overflow; do not let an
|
||||
// old valid primary block persistence of every subsequent conversation turn.
|
||||
ensureFullBackup();
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
const previousDiskOverflow = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
clearOverflowChunks();
|
||||
let primaryWritten = false;
|
||||
let primaryPersisted = false;
|
||||
try {
|
||||
for (const candidate of primaryCandidates) {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const diskOnlyIds = [...new Set([
|
||||
...unresolvedDiskIds,
|
||||
...pruned.filter(session => !primaryIds.has(session.id)).map(session => session.id),
|
||||
])];
|
||||
// Replacing a large old primary can free the space needed by metadata.
|
||||
// Roll it back below if the recovery marker still cannot be persisted.
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) continue;
|
||||
primaryWritten = true;
|
||||
if (writeDiskOverflowIds(diskOnlyIds)) {
|
||||
primaryPersisted = true;
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!primaryPersisted) {
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
if (previousDiskOverflow != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, previousDiskOverflow);
|
||||
} else if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
@@ -615,7 +846,7 @@ export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
readSessionsFromStorage();
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
|
||||
@@ -326,3 +326,20 @@ 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"));
|
||||
});
|
||||
@@ -68,33 +68,75 @@ 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.
|
||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||
cachedStore = persisted;
|
||||
knownClearGeneration = readClearGeneration(persisted);
|
||||
notifyCommandHistoryCleared();
|
||||
return true;
|
||||
// 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 ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(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, store);
|
||||
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
|
||||
}
|
||||
|
||||
function hasPendingSave(): boolean {
|
||||
return saveTimer !== null || saveIdleHandle !== null;
|
||||
}
|
||||
|
||||
function saveStore(store: HistoryStore): void {
|
||||
cachedStore = store;
|
||||
// Debounce saves to avoid excessive writes
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
// 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();
|
||||
saveTimer = setTimeout(() => {
|
||||
persistStoreNow(store);
|
||||
saveTimer = null;
|
||||
const persist = () => {
|
||||
saveIdleHandle = null;
|
||||
persistStoreNow(store);
|
||||
};
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
|
||||
} else {
|
||||
persist();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -103,6 +145,10 @@ function cancelPendingSave(): void {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
if (saveIdleHandle !== null) {
|
||||
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
|
||||
saveIdleHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,26 +175,14 @@ function applyExternalCommandHistoryWrite(
|
||||
rawNewValue: string | null,
|
||||
rawOldValue: string | null,
|
||||
): void {
|
||||
let incomingGeneration: number | null = null;
|
||||
if (rawNewValue) {
|
||||
try {
|
||||
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
||||
} catch {
|
||||
incomingGeneration = null;
|
||||
}
|
||||
}
|
||||
// Fires in every other window for each save; read just the generation.
|
||||
const incomingGeneration = readRawClearGeneration(rawNewValue);
|
||||
|
||||
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.
|
||||
try {
|
||||
previousGeneration = rawOldValue
|
||||
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
||||
: 0;
|
||||
} catch {
|
||||
previousGeneration = 0;
|
||||
}
|
||||
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
|
||||
}
|
||||
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
||||
cancelPendingSave();
|
||||
@@ -156,7 +190,7 @@ function applyExternalCommandHistoryWrite(
|
||||
knownClearGeneration = incomingGeneration;
|
||||
notifyCommandHistoryCleared();
|
||||
} else {
|
||||
if (!saveTimer) cachedStore = null;
|
||||
if (!hasPendingSave()) cachedStore = null;
|
||||
if (incomingGeneration !== null && knownClearGeneration === null) {
|
||||
knownClearGeneration = incomingGeneration;
|
||||
}
|
||||
|
||||
@@ -47,3 +47,45 @@ 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());
|
||||
});
|
||||
@@ -14,8 +14,30 @@ 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) {
|
||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
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;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
/** 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();
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
|
||||
['tab', 'tab'],
|
||||
]);
|
||||
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'[data-plugin-editing]',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
@@ -44,6 +45,10 @@ 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'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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: [] });
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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; },
|
||||
};
|
||||
}
|
||||
@@ -1,111 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
consumeClosedPluginViewInstance,
|
||||
reconcilePluginViewTabCatalog,
|
||||
markPluginViewOpenTokensClosed,
|
||||
reconcileClosedPluginView,
|
||||
rememberClosedPluginViewInstance,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
shouldReconcilePluginViewTabCatalog,
|
||||
withdrawPluginViewTab,
|
||||
type HostedPluginViewState,
|
||||
} from './pluginViewLifecycle.ts';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore.ts';
|
||||
|
||||
function view(id: string, tabId?: string): HostedPluginViewState {
|
||||
return {
|
||||
id,
|
||||
viewId: `view.${id}`,
|
||||
scopeId: 'window:main',
|
||||
retainContextWhenHidden: false,
|
||||
...(tabId ? { tabId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
test('host close events clear the active renderer instance and identify its native tab', () => {
|
||||
const current = view('active', 'plugin-view:publisher.plugin:view.active');
|
||||
const result = reconcileClosedPluginView({
|
||||
current,
|
||||
retained: new Map([['retained', view('retained')]]),
|
||||
instanceId: 'active',
|
||||
});
|
||||
|
||||
assert.equal(result.current, null);
|
||||
assert.equal(result.matchedCurrent, true);
|
||||
assert.equal(result.closedTabId, current.tabId);
|
||||
assert.equal(result.retained.size, 1);
|
||||
});
|
||||
|
||||
test('host close events remove retained instances without dismissing another active view', () => {
|
||||
const current = view('active');
|
||||
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
|
||||
const result = reconcileClosedPluginView({
|
||||
current,
|
||||
retained: new Map([
|
||||
['closed', retainedTab],
|
||||
['kept', view('retained-kept')],
|
||||
]),
|
||||
instanceId: 'retained-closed',
|
||||
});
|
||||
|
||||
assert.equal(result.current, current);
|
||||
assert.equal(result.matchedCurrent, false);
|
||||
assert.equal(result.matchedRetained, true);
|
||||
assert.deepEqual([...result.retained.keys()], ['kept']);
|
||||
assert.equal(result.closedTabId, retainedTab.tabId);
|
||||
});
|
||||
|
||||
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
|
||||
const tabId = 'plugin-view:publisher.plugin:view.shared';
|
||||
const result = withdrawPluginViewTab({
|
||||
current: view('active', tabId),
|
||||
retained: new Map([
|
||||
['same-tab', view('retained-same', tabId)],
|
||||
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
|
||||
]),
|
||||
tabId,
|
||||
});
|
||||
|
||||
assert.equal(result.current, null);
|
||||
assert.equal(result.matchedCurrent, true);
|
||||
assert.equal(result.matchedRetained, true);
|
||||
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
|
||||
assert.deepEqual([...result.retained.keys()], ['other-tab']);
|
||||
});
|
||||
|
||||
test('an early host close tombstone is consumed when the open response arrives later', () => {
|
||||
const tombstones = new Set<string>();
|
||||
rememberClosedPluginViewInstance(tombstones, 'instance-early');
|
||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
|
||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
|
||||
}
|
||||
assert.equal(tombstones.size, 256);
|
||||
assert.equal(tombstones.has('instance-0'), false);
|
||||
});
|
||||
|
||||
test('explicit close marks only in-flight opens owned by the closed surface', () => {
|
||||
const first = Symbol('first');
|
||||
const second = Symbol('second');
|
||||
const explicitlyClosed = new Set<symbol>();
|
||||
const opening = new Map<string, Set<symbol>>([
|
||||
['window:main\0view.first', new Set([first])],
|
||||
['window:main\0view.second', new Set([second])],
|
||||
]);
|
||||
|
||||
assert.equal(markPluginViewOpenTokensClosed(
|
||||
opening,
|
||||
explicitlyClosed,
|
||||
'window:main\0view.first',
|
||||
), 1);
|
||||
assert.deepEqual([...explicitlyClosed], [first]);
|
||||
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
|
||||
});
|
||||
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore';
|
||||
|
||||
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
||||
const previous = {
|
||||
@@ -184,30 +80,3 @@ 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);
|
||||
});
|
||||
@@ -1,11 +1,3 @@
|
||||
export interface HostedPluginViewState {
|
||||
id: string;
|
||||
viewId: string;
|
||||
scopeId: string;
|
||||
retainContextWhenHidden: boolean;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
export interface PluginViewSnapshotSelection<T> {
|
||||
requestViewId: string;
|
||||
contextKey: string;
|
||||
@@ -81,241 +73,3 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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,12 +393,10 @@ export function useSftpFollowTerminalCwd({
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
|
||||
@@ -3,6 +3,37 @@ 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,7 +1,10 @@
|
||||
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>();
|
||||
@@ -28,19 +31,40 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
|
||||
const register = (sessionId: string, handler: TerminalReconnectHandler,
|
||||
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => 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);
|
||||
@@ -66,7 +90,9 @@ export const createTerminalReconnectRegistry = () => {
|
||||
|
||||
return {
|
||||
register,
|
||||
inspect,
|
||||
request,
|
||||
requestIfAvailable,
|
||||
requestEnterReconnect,
|
||||
setEnterReconnectEnabled,
|
||||
setActive,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
|
||||
|
||||
migrateSidePanelTabLayoutIfNeeded();
|
||||
|
||||
export function useTerminalSidePanelTabOrder(): {
|
||||
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
|
||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||
layout: ToolbarItemLayout;
|
||||
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
|
||||
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,
|
||||
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
|
||||
partition,
|
||||
} = useToolbarItemLayout(
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
||||
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
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_OVERFLOW, STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { useAIState } from './useAIState';
|
||||
import { useAISessionsStore } from './aiSessionsStore';
|
||||
import { __resetBackupMirrorForTests, flushBackupMirror, readSessionsFromStorage, setLatestAIActiveSessionMapSnapshot, setLatestAISessionsSnapshot, writeSessionsForStorage } 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('startup and cross-window updates load overflow chats and mirror the merged history', 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 primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const overflow = { ...primary, id: 'overflow', title: 'Overflow', updatedAt: 1 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
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)); });
|
||||
assert.deepEqual(sessions, [primary, overflow]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), [primary, overflow]);
|
||||
const updatedOverflow = { ...overflow, title: 'Updated elsewhere', updatedAt: 3 };
|
||||
await act(async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([updatedOverflow]));
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_SESSIONS_OVERFLOW }));
|
||||
});
|
||||
assert.deepEqual(sessions, [updatedOverflow, primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['global', 'terminal'] as const) {
|
||||
test(`import retains disk-only chats in live history across rename and deletion (${scopeType})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] 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[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `imported-${index}`, title: `Imported ${index}`, agentId: 'catty',
|
||||
scope: scopeType === 'global' ? { type: 'global' } : { type: 'terminal', targetId: 'closed-terminal' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const ids = source.map(session => session.id);
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupImport: async () => ({ success: true, json: JSON.stringify(source) }),
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
} });
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, '[]');
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
__resetBackupMirrorForTests();
|
||||
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.cleanupOrphanedSessions(new Set()); });
|
||||
let importedCount = 0;
|
||||
await act(async () => { importedCount = await ai.importSessionsBackup(); });
|
||||
assert.equal(importedCount, source.length);
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.deepEqual(readSessionsFromStorage().map(session => session.id), [ids[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
assert.equal(sessions.every(session => !!session.scopeDangling === (scopeType === 'terminal')), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.updateSessionTitle(ids[0], 'Renamed after import'); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.equal(sessions[0].title, 'Renamed after import');
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.deleteSession(ids[5]); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids.slice(0, 5));
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1, 5));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('startup recovers disk-only chats before mirroring a valid partial primary', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] 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[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
__resetBackupMirrorForTests();
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), source);
|
||||
assert.deepEqual(readSessionsFromStorage(), [source[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), source.slice(1).map(session => session.id));
|
||||
// A newer local copy must win, and an old deleted chat in the backup must
|
||||
// stay deleted: only the explicitly recorded disk-only IDs are recovered.
|
||||
const primary = { ...source[0], title: 'Newer primary', updatedAt: 10 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
const expected = [primary, ...source.slice(1)];
|
||||
const staleBackup = JSON.stringify([...source, { ...source[5], id: 'deleted-chat' }]);
|
||||
backups.length = 0;
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
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)); });
|
||||
assert.equal(reads, 1);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
await act(async () => { resolveRead({ ok: true, json: staleBackup }); });
|
||||
assert.deepEqual(sessions, expected);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), [primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['terminal', 'workspace'] as const) {
|
||||
test(`startup orphan cleanup preserves pending disk-only recovery (${scopeType})`, 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 primary: AISession = {
|
||||
id: 'closed-primary', title: 'Closed primary', agentId: 'catty',
|
||||
scope: { type: scopeType, targetId: 'closed-target' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly: AISession = {
|
||||
...primary, id: 'disk-only', title: 'Disk only', updatedAt: 1,
|
||||
scope: { type: scopeType, targetId: 'another-closed-target' },
|
||||
};
|
||||
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]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
const ai = useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
// TerminalLayerSupport performs this cleanup on mount, while the disk
|
||||
// backup IPC read can still be pending.
|
||||
React.useEffect(() => { ai.cleanupOrphanedSessions(new Set()); }, [ai.cleanupOrphanedSessions]);
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 1);
|
||||
assert.deepEqual(sessions, [{ ...primary, scopeDangling: true }]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = [primary, diskOnly].map(session => ({ ...session, scopeDangling: true }));
|
||||
assert.deepEqual(sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const mutation of ['rename', 'create', 'delete', 'delete-pending'] as const) {
|
||||
test(`live ${mutation} preserves intended history across pending disk recovery`, 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 primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly = { ...primary, id: 'disk-only', updatedAt: 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]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, JSON.stringify({ 'global:': diskOnly.id }));
|
||||
setLatestAIActiveSessionMapSnapshot({ 'global:': diskOnly.id });
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => new Promise(resolve => { resolveRead = resolve; }),
|
||||
} });
|
||||
let ai!: ReturnType<typeof useAIState>;
|
||||
let snapshot!: ReturnType<typeof useAISessionsStore>;
|
||||
function Harness() {
|
||||
ai = useAIState();
|
||||
snapshot = useAISessionsStore();
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], diskOnly.id);
|
||||
await act(async () => {
|
||||
if (mutation === 'rename') ai.updateSessionTitle(primary.id, 'Renamed before recovery');
|
||||
else if (mutation === 'create') ai.createSession({ type: 'global' }, 'catty');
|
||||
else ai.deleteSession(mutation === 'delete-pending' ? diskOnly.id : primary.id);
|
||||
});
|
||||
const liveBeforeRecovery = [...snapshot.sessions];
|
||||
const expectedActiveId = mutation === 'create' ? liveBeforeRecovery[0].id
|
||||
: mutation === 'delete-pending' ? null : diskOnly.id;
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], expectedActiveId);
|
||||
assert.deepEqual(readSessionsFromStorage(), liveBeforeRecovery);
|
||||
const recoveryMarker = dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
assert.deepEqual(recoveryMarker == null ? null : JSON.parse(recoveryMarker), mutation === 'delete-pending' ? null : [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), mutation === 'delete-pending' ? [liveBeforeRecovery] : []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = mutation === 'delete-pending' ? liveBeforeRecovery : [...liveBeforeRecovery, diskOnly];
|
||||
assert.deepEqual(snapshot.sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('count-cap eviction clears a loaded disk-only marker and resumes backup mirroring', () => {
|
||||
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] });
|
||||
}
|
||||
const source: AISession[] = Array.from({ length: 51 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 51 - index, messages: [],
|
||||
}));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([source[50].id]));
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
__resetBackupMirrorForTests();
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), source.slice(0, 50));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), [source.slice(0, 50)]);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const keepPrimary of [true, false]) {
|
||||
test(`deletion clears disk-only recovery and remains authoritative (keep primary ${keepPrimary})`, 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 primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['deleted-chat']));
|
||||
const remaining = keepPrimary ? [primary] : [];
|
||||
assert.equal(writeSessionsForStorage(remaining, { deletedSessionIds: ['deleted-chat'] }), true);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let reads = 0;
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupReadLatest: async () => {
|
||||
reads += 1;
|
||||
return { ok: true, json: JSON.stringify([primary, { ...primary, id: 'deleted-chat' }]) };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
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)); });
|
||||
assert.equal(reads, 0);
|
||||
assert.deepEqual(sessions, remaining);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
prepareRestoredSessions,
|
||||
readSessionsFromStorage,
|
||||
scheduleBackupMirror,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
@@ -85,9 +88,9 @@ import {
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
parseSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
@@ -180,49 +183,55 @@ export function useAIState() {
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
?? readSessionsFromStorage()
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
// Disk-only overflow IDs also permit recovery, without reviving deleted chats.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
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);
|
||||
const liveStore = parseSessionsStore(liveRaw);
|
||||
const liveSessions = readSessionsFromStorage();
|
||||
const diskOnlyRecovery = (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0;
|
||||
if (!diskOnlyRecovery && (liveStore.valid || liveSessions.length > 0)) {
|
||||
if (liveSessions.length > 0) scheduleBackupMirror(JSON.stringify(liveSessions));
|
||||
return;
|
||||
}
|
||||
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
if (!diskOnlyRecovery && latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
const canRestore = () => diskOnlyRecovery
|
||||
? (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0
|
||||
: isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
&& readSessionsFromStorage().length === 0
|
||||
&& (latestAISessionsSnapshot?.length ?? 0) === 0
|
||||
&& sessionsRef.current.length === 0;
|
||||
void (async () => {
|
||||
try {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
|
||||
if (!canRestore()) 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 both persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
// Re-check after the async gap so live mutations and deletions win.
|
||||
if (!canRestore()) return;
|
||||
const backupSessions = parseBackupSessions(backup.json);
|
||||
if (!backupSessions) return;
|
||||
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||
const recovered = diskOnlyRecovery
|
||||
? mergeImportedSessions(
|
||||
backupSessions.filter(session => diskOnlyIds.has(session.id)),
|
||||
latestAISessionsSnapshot ?? readSessionsFromStorage(),
|
||||
)
|
||||
: backupSessions;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
const restored = prepareRestoredSessions(recovered, 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);
|
||||
@@ -324,6 +333,11 @@ export function useAIState() {
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
||||
// Disk-only chats may not have arrived from the startup backup read yet.
|
||||
// Keep their active pointers and panel views until recovery or deletion.
|
||||
for (const id of localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []) {
|
||||
validSessionIds.add(id);
|
||||
}
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
@@ -543,7 +557,9 @@ export function useAIState() {
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
switch (e.key) {
|
||||
const key = e.key === STORAGE_KEY_AI_SESSIONS_OVERFLOW || e.key?.startsWith(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_`)
|
||||
? STORAGE_KEY_AI_SESSIONS : e.key;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) {
|
||||
@@ -635,7 +651,7 @@ export function useAIState() {
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
const nextSessions = readSessionsFromStorage();
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
@@ -677,8 +693,7 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [],
|
||||
?? readSessionsFromStorage(),
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
@@ -730,8 +745,8 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
writeSessionsForStorage(next);
|
||||
const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
|
||||
writeSessionsForStorage(next, { deletedSessionIds });
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
@@ -876,7 +891,7 @@ export function useAIState() {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
persistSessions(next, [sessionId]);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
@@ -920,7 +935,7 @@ export function useAIState() {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
persistSessions(next, removedSessionIds);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
@@ -1016,11 +1031,12 @@ 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));
|
||||
// Local readback can omit disk-only overflow chats. Keep the complete
|
||||
// prepared history live so cleanup and later writes retain those chats.
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = persisted;
|
||||
setLatestAISessionsSnapshot(persisted);
|
||||
setSessionsRaw(persisted);
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
setSessionsRaw(next);
|
||||
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
|
||||
@@ -1029,7 +1045,7 @@ export function useAIState() {
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? persisted;
|
||||
?? next;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
@@ -1037,7 +1053,7 @@ export function useAIState() {
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return persisted.filter(session => importedIds.has(session.id)).length;
|
||||
return nextSessions.filter(session => importedIds.has(session.id)).length;
|
||||
}, []);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
@@ -1053,16 +1069,17 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
// Local-only retention. Synced history must match the full-history storage policy.
|
||||
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;
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Trim oldest messages if exceeding limit (keep system messages)
|
||||
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
// Dropping synced prefixes makes the next cloud merge add them back.
|
||||
if (!syncEnabled && 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 - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
@@ -1308,8 +1325,7 @@ export function useAIState() {
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readSessionsFromStorage();
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
@@ -1361,8 +1377,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readSessionsFromStorage();
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
@@ -1407,8 +1422,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readSessionsFromStorage();
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
|
||||
@@ -11,6 +11,10 @@ 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>();
|
||||
@@ -98,6 +102,60 @@ 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("active chrome theme foreground uses contrast calculation", () => {
|
||||
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
||||
import { useSettingsChromeStore } from './settingsChromeStore';
|
||||
import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
@@ -239,7 +239,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
if (typeof window !== "undefined") {
|
||||
netcattyBridge.get()?.setTheme?.(targetClass);
|
||||
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
|
||||
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
}
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
|
||||
@@ -92,3 +92,21 @@ 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);
|
||||
});
|
||||
@@ -43,7 +43,20 @@ export function selectPreferredRuntimeAppLockState(
|
||||
if (incoming.version < current.version) return current;
|
||||
if (incoming.initialized && !current.initialized) return incoming;
|
||||
if (!incoming.initialized && current.initialized) return current;
|
||||
return incoming;
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function useAppLockRuntime(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
normalizeAppLockSettings,
|
||||
@@ -131,6 +131,8 @@ 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)}`;
|
||||
@@ -160,8 +162,14 @@ 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]);
|
||||
|
||||
@@ -249,7 +257,9 @@ export function useAppLockState(settings: AppLockSettings) {
|
||||
void refreshSystemUnlockStatus();
|
||||
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
|
||||
|
||||
return {
|
||||
// Stable identity: AppLockGate passes this to <App>, whose provider tree
|
||||
// re-renders whenever it changes.
|
||||
return useMemo(() => ({
|
||||
initialized: effectiveRuntimeState.initialized,
|
||||
locked: effectiveRuntimeState.locked,
|
||||
lockReason: effectiveRuntimeState.reason,
|
||||
@@ -261,5 +271,17 @@ export function useAppLockState(settings: AppLockSettings) {
|
||||
reset,
|
||||
recordActivity,
|
||||
resync: refreshRuntimeState,
|
||||
};
|
||||
}), [
|
||||
effectiveRuntimeState.initialized,
|
||||
effectiveRuntimeState.locked,
|
||||
effectiveRuntimeState.reason,
|
||||
lockNow,
|
||||
unlock,
|
||||
unlockWithSystemAuth,
|
||||
systemUnlockStatus,
|
||||
refreshSystemUnlockStatus,
|
||||
reset,
|
||||
recordActivity,
|
||||
refreshRuntimeState,
|
||||
]);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useMonitorPanels } from "./monitorPanelsStorage";
|
||||
import {
|
||||
monitorActions,
|
||||
getMonitorHistoryCursor,
|
||||
isIdleMonitorSnapshot,
|
||||
publishMonitorSnapshot,
|
||||
publishMonitorUnavailable,
|
||||
} from "./monitorRuntimeStore";
|
||||
@@ -157,16 +158,24 @@ 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 &&
|
||||
@@ -186,13 +195,16 @@ export function useMonitorRuntime() {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
const schedule = () => {
|
||||
if (disposed) return;
|
||||
timer = setTimeout(() => {
|
||||
void poll().finally(schedule);
|
||||
}, hasRunningPanels || remoteRunning ? 1000 : 10_000);
|
||||
};
|
||||
void poll().finally(schedule);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
}, [hasRunningPanels]);
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import type { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -27,30 +26,47 @@ 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 }>();
|
||||
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();
|
||||
}),
|
||||
// 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 unsubscribe = bridge.onPluginAIHostRequest((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
@@ -60,24 +76,28 @@ 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();
|
||||
runtime.releaseRuntime(request.runtimeId);
|
||||
withLoadedRuntime((loaded) => loaded.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 runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
|
||||
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(
|
||||
(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 = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
|
||||
const pageHide = () => { withLoadedRuntime((loaded) => loaded.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(); runtime.shutdown();
|
||||
requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
|
||||
void bridge.setPluginAIHostReady(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
|
||||
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
|
||||
import type { 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,58 +22,78 @@ 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 }>();
|
||||
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();
|
||||
}),
|
||||
});
|
||||
// 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 unsubscribe = bridge.subscribe((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
const pending = requests.get(request.payload.requestId);
|
||||
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
|
||||
withLoadedRuntime((loaded) => loaded.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();
|
||||
runtime.releaseRuntime(request.runtimeId); return;
|
||||
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId)); return;
|
||||
}
|
||||
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
|
||||
if (request.kind === 'agent.close') { withLoadedRuntime((loaded) => loaded.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 runtime.handle(request, controller.signal).then(
|
||||
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(
|
||||
(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 = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
|
||||
const pageHide = () => { withLoadedRuntime((loaded) => loaded.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))) {
|
||||
runtime.invalidateConfiguration();
|
||||
withLoadedRuntime((loaded) => loaded.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(() => {});
|
||||
@@ -85,7 +105,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(); runtime.shutdown();
|
||||
for (const item of requests.values()) item.controller.abort(); requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
|
||||
void bridge.ready(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -86,14 +86,8 @@ 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(
|
||||
@@ -189,32 +183,6 @@ 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);
|
||||
@@ -223,10 +191,6 @@ 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,
|
||||
@@ -245,13 +209,7 @@ export function usePluginContributions(
|
||||
updateSetting,
|
||||
resetSetting,
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -38,18 +38,20 @@ 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>) => {
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let failure: Error | null = null;
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
if (throwOnError && failure) throw failure;
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
@@ -70,6 +72,6 @@ export function usePluginManager() {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}),
|
||||
}, true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 };
|
||||
}
|
||||
@@ -14,19 +14,12 @@ import {
|
||||
usePluginViewTabs,
|
||||
} from './pluginViewTabStore';
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
reconcilePluginViewTabCatalog,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
type HostedPluginViewState,
|
||||
type PluginViewSnapshotSelection,
|
||||
} from './pluginViewLifecycle';
|
||||
import {
|
||||
canRetainPluginViewInScope,
|
||||
resolvePluginRetainedViewKey,
|
||||
resolvePluginViewWindowScope,
|
||||
} from './pluginViewScopes';
|
||||
import { pluginViewSessions } from './pluginViewSession';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
|
||||
|
||||
@@ -70,17 +63,8 @@ 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,
|
||||
@@ -93,16 +77,6 @@ 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
|
||||
@@ -114,26 +88,6 @@ 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)
|
||||
@@ -182,164 +136,16 @@ export function usePluginViewLifecycle({
|
||||
setRequested(null);
|
||||
}, [activeView, requested]);
|
||||
|
||||
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]);
|
||||
useEffect(() => {
|
||||
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
|
||||
return () => { stop(); };
|
||||
}, []);
|
||||
|
||||
const close = useCallback(async () => {
|
||||
lifecycleRef.current.markViewClosed(retainedViewKey);
|
||||
const current = lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
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]);
|
||||
if (activeView) pluginViewSessions.closeView(activeView.view.id);
|
||||
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
|
||||
}, [activePluginTab, activeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contributions.available) return;
|
||||
@@ -376,16 +182,10 @@ 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,
|
||||
mountRef,
|
||||
viewQueryContext,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
setWorkspaceFocusedSessionId,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
@@ -249,6 +250,8 @@ 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);
|
||||
@@ -1097,10 +1100,10 @@ export const useSessionState = ({
|
||||
|
||||
// Set the focused session in a workspace (for focus mode)
|
||||
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return { ...ws, focusedSessionId: sessionId };
|
||||
}));
|
||||
// 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));
|
||||
}, []);
|
||||
|
||||
const reorderWorkspaceSessions = useCallback((
|
||||
@@ -1126,7 +1129,7 @@ export const useSessionState = ({
|
||||
|
||||
// Move focus between panes in a workspace
|
||||
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
const workspace = workspacesRef.current.find(w => w.id === workspaceId);
|
||||
if (!workspace) {
|
||||
return false;
|
||||
}
|
||||
@@ -1147,13 +1150,10 @@ export const useSessionState = ({
|
||||
}
|
||||
|
||||
// Update focused session
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return { ...ws, focusedSessionId: nextSessionId };
|
||||
}));
|
||||
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, 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,6 +1196,10 @@ 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.
|
||||
@@ -1230,6 +1234,13 @@ 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 => {
|
||||
@@ -1250,17 +1261,13 @@ export const useSessionState = ({
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIds = tabIdsAtCall;
|
||||
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Copy a whole workspace (split/focus tab): clone every pane's session and
|
||||
// reproduce the layout tree in a new tab.
|
||||
@@ -1268,7 +1275,14 @@ export const useSessionState = ({
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
perPaneCwd?: Record<string, string | undefined>;
|
||||
}) => {
|
||||
const sourceWorkspace = workspaces.find(w => w.id === workspaceId);
|
||||
// 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);
|
||||
if (!sourceWorkspace) return;
|
||||
|
||||
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
|
||||
@@ -1306,17 +1320,13 @@ export const useSessionState = ({
|
||||
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
|
||||
setActiveTabId(newWorkspaceId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIds = tabIdsAtCall;
|
||||
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
|
||||
});
|
||||
|
||||
return [...prevSessions, ...built.newSessions];
|
||||
});
|
||||
}, [workspaces, orphanSessions, logViews, setActiveTabId]);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
|
||||
@@ -95,7 +95,6 @@ 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" });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
|
||||
@@ -70,26 +70,55 @@ export function shouldReleaseShortcutModifier(
|
||||
return !isShortcutModifierHeld(event, requirements);
|
||||
}
|
||||
|
||||
export function useShortcutModifierHeld(
|
||||
// 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(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): boolean {
|
||||
const [isHeld, setIsHeld] = useState(false);
|
||||
): void {
|
||||
const requirements = useMemo(
|
||||
() => getShortcutModifierRequirements(keyBinding, scheme),
|
||||
[keyBinding, scheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeld(false);
|
||||
publishModifierHeld(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) => {
|
||||
setIsHeld(isShortcutModifierHeld(event, requirements));
|
||||
publishModifierHeld(isShortcutModifierHeld(event, requirements));
|
||||
};
|
||||
const clearHeldState = () => setIsHeld(false);
|
||||
const clearHeldState = () => publishModifierHeld(false);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
clearHeldState();
|
||||
@@ -106,8 +135,7 @@ export function useShortcutModifierHeld(
|
||||
window.removeEventListener('keyup', syncHeldState, true);
|
||||
window.removeEventListener('blur', clearHeldState);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
publishModifierHeld(false);
|
||||
};
|
||||
}, [requirements]);
|
||||
|
||||
return isHeld;
|
||||
}
|
||||
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
|
||||
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
|
||||
|
||||
useEffect(() => {
|
||||
// Defaults can include dynamically discovered items; re-read without writing
|
||||
// so temporary catalog changes never erase stored toolbar preferences.
|
||||
setLayout(readLayout(storageKey, defaults));
|
||||
const sync = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== storageKey) return;
|
||||
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface UseVaultAgentBridgeInput {
|
||||
openHost?: VaultAgentApiDeps['openHost'];
|
||||
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
|
||||
closeSession?: VaultAgentApiDeps['closeSession'];
|
||||
reconnectSession?: VaultAgentApiDeps['reconnectSession'];
|
||||
inspectSessionRecovery?: VaultAgentApiDeps['inspectSessionRecovery'];
|
||||
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
|
||||
}
|
||||
|
||||
@@ -273,6 +275,8 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
|
||||
: undefined,
|
||||
findExistingSessionForHost: current.findExistingSessionForHost,
|
||||
reconnectSession: current.reconnectSession,
|
||||
inspectSessionRecovery: current.inspectSessionRecovery,
|
||||
closeSession: current.closeSession
|
||||
? (sessionId) => current.closeSession!(sessionId)
|
||||
: undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { act, create } from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
|
||||
@@ -79,6 +79,17 @@ test('sync persistence survives restart beyond the local-only history limits', (
|
||||
assert.equal(localOnly[0].messages.length, 200);
|
||||
});
|
||||
|
||||
test('sync collection after restart includes overflow before clearing it', () => {
|
||||
const primary = session();
|
||||
const overflow = { ...session(), id: 'overflow-chat', updatedAt: 1 };
|
||||
save([primary], true);
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.map(value => value.id), [primary.id, overflow.id]);
|
||||
assert.equal(storage.has(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!).map((value: AISession) => value.id), [primary.id, overflow.id]);
|
||||
});
|
||||
|
||||
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
|
||||
save([session()]);
|
||||
const original = globalThis.localStorage.setItem;
|
||||
|
||||
@@ -51,6 +51,7 @@ import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import {
|
||||
latestAISessionsSnapshot,
|
||||
readSessionsFromStorage,
|
||||
setLatestAISessionsSnapshot,
|
||||
writeSessionsForStorage,
|
||||
cleanupSdkAgentSessions,
|
||||
@@ -1073,8 +1074,7 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
|
||||
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
|
||||
return latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<SyncPayload['aiSessions']>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readSessionsFromStorage();
|
||||
}
|
||||
|
||||
/** Build the portable payload; device-local data is added by the backup builder. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
|
||||
import { getLoadedLocaleMessages, loadLocaleMessages } from "../application/i18n/messages.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
|
||||
@@ -107,9 +107,11 @@ test("history drawer filters, pages, clears and explains the bounded search in e
|
||||
assert.equal(closed, 1);
|
||||
|
||||
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
|
||||
// Non-English dictionaries load on demand.
|
||||
await loadLocaleMessages(locale);
|
||||
await render(sessions, locale);
|
||||
await enterQuery("no-such-content");
|
||||
const messages = MESSAGES_BY_LOCALE[locale];
|
||||
const messages = getLoadedLocaleMessages(locale)!;
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
|
||||
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
|
||||
|
||||
@@ -112,6 +112,7 @@ export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGatePr
|
||||
setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
if (shouldNotifyRendererReady) {
|
||||
performance.mark('netcatty:renderer:ready');
|
||||
notifyAppLockRendererReady();
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { loadLocaleMessages } from "../application/i18n/messages.ts";
|
||||
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
|
||||
import {
|
||||
createDomRenderer,
|
||||
@@ -342,6 +343,7 @@ test("AppLockOverlay renders platform-specific system unlock button", async () =
|
||||
});
|
||||
|
||||
test("AppLockOverlay localizes the system unlock button label", async () => {
|
||||
await loadLocaleMessages("zh-CN");
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { loadLocaleMessages } from '../application/i18n/messages';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
|
||||
test('backup cleanup confirms, preserves data on cancel, refreshes after retry and explains decrypt failures', async () => {
|
||||
await loadLocaleMessages('zh-CN');
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
@@ -37,6 +39,9 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
|
||||
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
|
||||
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
}] : [],
|
||||
readVaultBackup: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:vaultBackups:read': Error: VAULT_BACKUP_DECRYPT_FAILED");
|
||||
},
|
||||
clearVaultBackups: async () => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('access denied');
|
||||
@@ -57,6 +62,8 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? '', /This folder alone is not a complete, portable backup/);
|
||||
assert.match(dom.document.body.textContent ?? '', /Deleting or wiping the app profile \(userData\)/);
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
|
||||
@@ -90,6 +97,19 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
|
||||
assert.match(notices[1], /^success:/);
|
||||
|
||||
hasBackup = true;
|
||||
await renderer.render(<I18nProvider locale="zh-CN"><LocalBackupsPanel key="restore" onApplyPayload={() => { throw new Error('must not apply unreadable backup'); }} /></I18nProvider>);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: true }); });
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? '', /仅复制此文件夹并不构成完整、可迁移的备份/);
|
||||
await click(button('恢复'));
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
await click(button('恢复', dialog));
|
||||
assert.match(notices[2], /当前应用配置无法解密此备份/);
|
||||
assert.match(notices[2], /可能是加密密钥丢失、应用配置已更改,或备份已损坏/);
|
||||
assert.doesNotMatch(notices[2], /VAULT_BACKUP_DECRYPT_FAILED|decryptString/);
|
||||
} finally {
|
||||
toast.success = success;
|
||||
toast.error = error;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
@@ -60,6 +59,9 @@ function errorKey(error: string | undefined): string {
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
// Loaded on demand: the decoder is only needed when importing a QR image,
|
||||
// but the host editor (and with it this module) is part of the startup bundle.
|
||||
const { default: jsQR } = await import("jsqr");
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
@@ -147,8 +147,8 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
await service.manager.initialize();
|
||||
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
const sample = path.join(rootPath, 'examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
|
||||
@@ -161,13 +161,13 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
|
||||
}
|
||||
};
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'netcattyView', { value: {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
assert.equal(noteChoice.options.length, 2);
|
||||
@@ -209,7 +209,7 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
console.error = originalConsoleError;
|
||||
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
|
||||
// the sample's pending callbacks before teardown closes its document.
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
if (panel) { panel.window.disposePanel(); panel.window.close(); }
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
|
||||
|
||||
@@ -90,8 +90,8 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
|
||||
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
|
||||
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
|
||||
@@ -104,14 +104,14 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
|
||||
} };
|
||||
let viewOpen = true;
|
||||
const mountPanel = async () => {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
|
||||
// PluginViewHost.close removes sender authority before WebContents
|
||||
// closes. A pagehide RPC cannot be relied on to reach this relay.
|
||||
if (!viewOpen) throw Error('Untrusted plugin view sender');
|
||||
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
|
||||
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
|
||||
};
|
||||
await mountPanel();
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
@@ -135,7 +135,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
|
||||
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
|
||||
const beforeReopen = counts.requests;
|
||||
viewOpen = false;
|
||||
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
|
||||
panel!.window.disposePanel(); panel!.window.close();
|
||||
viewOpen = true; await mountPanel();
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
|
||||
@@ -182,7 +182,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
if (panel) { panel.window.disposePanel(); panel.window.close(); }
|
||||
await act(async () => root.unmount()); await service?.manager.shutdown();
|
||||
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
|
||||
|
||||
@@ -5,6 +5,27 @@ import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Test-only observer preserves the security-surface visibility assertions.
|
||||
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();
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
@@ -25,7 +46,6 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
|
||||
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
|
||||
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
|
||||
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
|
||||
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
|
||||
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
|
||||
|
||||
@@ -69,8 +69,8 @@ test('registered selected-summary Ask is one host chooser; Allow still selects a
|
||||
await service.manager.initialize();
|
||||
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
|
||||
const sample = path.resolve('examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
|
||||
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
install('PointerEvent', dom.window.MouseEvent);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
let rectX = 200;
|
||||
const rectX = 200;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
|
||||
const { default: React, act, useCallback, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('./ui/tooltip');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
|
||||
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
|
||||
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
|
||||
let selectedSession = 'synthetic-session-a';
|
||||
const getSession = () => selectedSession;
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
|
||||
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
|
||||
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
|
||||
const visible = new Map<string, boolean>();
|
||||
const changed = new Set<() => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
|
||||
onPluginViewClosed() { return () => {}; },
|
||||
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); },
|
||||
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
|
||||
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
|
||||
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
|
||||
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView() {},
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
|
||||
if (!page || !target) return null;
|
||||
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
|
||||
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}</>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
try {
|
||||
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
|
||||
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
|
||||
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
|
||||
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
|
||||
await act(async () => { firstButton.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
|
||||
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
|
||||
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
await act(async () => { split(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
|
||||
assert.equal(opens.length, 2);
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
|
||||
focus.length = 0;
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
|
||||
const old = opens.map((item) => item.instanceId!);
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
|
||||
assert.equal(opens.length, 4);
|
||||
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
|
||||
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
|
||||
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
|
||||
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
|
||||
const last = opens.at(-1)!.instanceId!;
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
|
||||
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
|
||||
await act(async () => { localPage.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
|
||||
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
|
||||
let changed: (() => void) | undefined;
|
||||
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
|
||||
let defer: ((value: { instanceId: string }) => void) | undefined;
|
||||
let deferOpen = false;
|
||||
let delayLayout = false;
|
||||
const rejectOldLayout: Array<(error: Error) => void> = [];
|
||||
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
|
||||
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
|
||||
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
|
||||
onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView(id: string) { closes.push(id); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
|
||||
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
|
||||
assert.equal(opens.length, 1);
|
||||
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
|
||||
assert.equal(visibility.at(-1), true);
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.equal(visibility.at(-1), true);
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
|
||||
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(opens.length, 2);
|
||||
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
assert.ok(closes.includes(opens[1].instanceId!));
|
||||
// The main process owns immediate invalidation on disable/uninstall.
|
||||
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
|
||||
assert.ok(closes.includes(opens[0].instanceId!));
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
assert.equal(document.activeElement?.textContent, 'Hosts');
|
||||
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
|
||||
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
|
||||
deferOpen = true;
|
||||
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
|
||||
assert.equal(opens.length, 3);
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
|
||||
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
|
||||
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
|
||||
deferOpen = false;
|
||||
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
|
||||
delayLayout = true;
|
||||
await clickPage();
|
||||
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
delayLayout = false;
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
|
||||
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import test, { before } from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { loadLocaleMessages } from "../application/i18n/messages";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
@@ -40,6 +41,12 @@ const renderSwitcher = (
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Non-English dictionaries load on demand; static rendering runs no effects.
|
||||
before(async () => {
|
||||
await loadLocaleMessages("zh-TW");
|
||||
await loadLocaleMessages("zh-CN");
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
|
||||
@@ -150,7 +150,7 @@ export function getQuickSwitcherVisualRowHeight(row: QuickSwitcherVisualRow): nu
|
||||
return QS_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
interface QuickSwitcherProps {
|
||||
export interface QuickSwitcherProps {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
results: Host[];
|
||||
|
||||
+92
-17
@@ -2,6 +2,7 @@ import { publishTerminalSelectionTarget } from "../infrastructure/plugins/termin
|
||||
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
|
||||
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
|
||||
import { probeAltScreenScheduling } from "./terminal/runtime/terminalWriteCoalescer";
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
|
||||
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
|
||||
import { CONNECTION_PROGRESS_START, capConnectionProgressLogs } from "./terminal/connectionProgress";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
||||
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
||||
@@ -63,6 +64,7 @@ import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminal
|
||||
import {
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isSensitiveTerminalChallenge,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist";
|
||||
@@ -155,6 +157,7 @@ import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalO
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
isAuthFailureMessage,
|
||||
type PendingAuth,
|
||||
type TerminalSessionDataMeta,
|
||||
} from "./terminal/runtime/createTerminalSessionStarters";
|
||||
@@ -167,7 +170,7 @@ import {
|
||||
} from "./terminal/runtime/createXTermRuntime";
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isTerminalReadyForCommandInjection, registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
@@ -206,7 +209,7 @@ import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
||||
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
|
||||
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
|
||||
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction, waitForOsc7SessionOutput } from "./terminal/osc7Setup";
|
||||
import {
|
||||
getRemoteClipboardImageUploadErrorMessageKey,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
@@ -463,6 +466,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const promptSecurityScreenRef = useRef<{
|
||||
// Null until a raw transition or snapshot establishes authoritative screen state.
|
||||
active: boolean | null;
|
||||
incomplete: Parameters<typeof probeAltScreenScheduling>[1];
|
||||
}>({ active: null, incomplete: null });
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -505,6 +513,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const availableFonts = useAvailableFonts();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const isPromptSecurityAlternateScreenActive = useCallback(() => (
|
||||
promptSecurityScreenRef.current.active
|
||||
?? (termRef.current ? isTerminalAlternateScreenActive(termRef.current) : false)
|
||||
), []);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
@@ -602,6 +614,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
|
||||
const reconnectWakeTokenRef = useRef<symbol | null>(null);
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
const agentRecoveryStoppedRef = useRef(false);
|
||||
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
|
||||
// connection: reusing a live/idle pooled transport multiplexes onto the old
|
||||
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
|
||||
@@ -911,8 +924,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [progressLogs, setProgressLogs] = useState<string[]>([]);
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [progressLogs, setProgressLogsState] = useState<string[]>([]);
|
||||
// Every writer (starters, auto-reconnect, cancel) appends; an outage with
|
||||
// auto-reconnect on (retry every 5 s, no attempt cap) used to grow this list
|
||||
// and its unvirtualized dialog render for as long as the tab stayed open.
|
||||
const setProgressLogs = useCallback((update: React.SetStateAction<string[]>) => {
|
||||
setProgressLogsState((prev) => capConnectionProgressLogs(
|
||||
typeof update === "function" ? update(prev) : update,
|
||||
));
|
||||
}, []);
|
||||
// Deadline of the running connection timeout; the dialog counts down from
|
||||
// it locally so per-second ticks do not re-render Terminal/TerminalView.
|
||||
const [connectionDeadlineAt, setConnectionDeadlineAt] = useState<number | null>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [isSessionLogging, setIsSessionLogging] = useState(false);
|
||||
@@ -1115,6 +1138,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
sensitivePromptSource: passwordPromptActiveRef.source,
|
||||
alternateScreen: isPromptSecurityAlternateScreenActive(),
|
||||
})
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& onBroadcastInputRef.current
|
||||
@@ -1390,6 +1415,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
|
||||
writeToSession: terminalBackend.writeToSession,
|
||||
writeLocalTerminalData,
|
||||
isTerminalReadyForCommandInjection: () => isTerminalReadyForCommandInjection(sessionId),
|
||||
waitForSessionOutput: (id, predicate, timeoutMs) =>
|
||||
waitForOsc7SessionOutput(terminalBackend.onSessionData, id, predicate, timeoutMs),
|
||||
}).then((result) => {
|
||||
handleOsc7SetupOpenChange(false);
|
||||
if (result.success) {
|
||||
@@ -1413,6 +1441,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
terminalBackend.setupOsc7Tracking,
|
||||
terminalBackend.writeToSession,
|
||||
terminalBackend.onSessionData,
|
||||
writeLocalTerminalData,
|
||||
]);
|
||||
|
||||
@@ -1501,6 +1530,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, []);
|
||||
|
||||
const updateStatus = useCallback((next: TerminalSession["status"]) => {
|
||||
if (next === "disconnected") {
|
||||
promptSecurityScreenRef.current = { active: null, incomplete: null };
|
||||
}
|
||||
statusRef.current = next;
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
@@ -1567,7 +1599,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
|
||||
|
||||
return true;
|
||||
}, [attachExistingSession, host, t, terminalSettings, updateStatus]);
|
||||
}, [attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus]);
|
||||
|
||||
const prepareRestoredReconnect = useCallback(() => {
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect(
|
||||
@@ -1722,6 +1754,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
throw new Error("Terminal output did not settle before applying the snapshot");
|
||||
}
|
||||
}
|
||||
promptSecurityScreenRef.current = {
|
||||
active: payload.alternateScreen,
|
||||
incomplete: null,
|
||||
};
|
||||
if (typeof payload.perCharacterInput === "boolean") {
|
||||
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
|
||||
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
|
||||
@@ -2055,6 +2091,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
// PTY output reaches this observer before coalesced writes finish parsing.
|
||||
const screenProbe = probeAltScreenScheduling(chunk, promptSecurityScreenRef.current.incomplete);
|
||||
promptSecurityScreenRef.current = {
|
||||
active: screenProbe.lastTransition === null
|
||||
? promptSecurityScreenRef.current.active
|
||||
: screenProbe.lastTransition === "enter",
|
||||
incomplete: screenProbe.incomplete,
|
||||
};
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
@@ -2065,9 +2109,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
alternateScreen: promptSecurityScreenRef.current.active ?? (termRef.current
|
||||
? isTerminalAlternateScreenActive(termRef.current)
|
||||
: false,
|
||||
: false),
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
||||
@@ -2082,17 +2126,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
} else if (passwordPromptActiveRef.current && passwordPromptActiveRef.source === "explicit") {
|
||||
return;
|
||||
} else if (isSensitiveTerminalChallenge(folded.promptText)
|
||||
|| (!(promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart()))
|
||||
&& isUntrustedTerminalInputPrompt(
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
))) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
} else if ((promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart())) || isConfirmedTerminalShellPrompt(
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
// Vim ex can end a stale heuristic latch on the alternate screen (#4107).
|
||||
// Other output must confirm a shell prompt: background job output can
|
||||
// arrive while a password is still expected, including inside tmux.
|
||||
passwordPromptActiveRef.current = false;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
}
|
||||
@@ -3443,6 +3494,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: passwordPromptActiveRef.current,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
|
||||
alternateScreen: isPromptSecurityAlternateScreenActive(),
|
||||
})
|
||||
&& sessionRef.current
|
||||
&& isBroadcastEnabledRef.current
|
||||
@@ -3460,7 +3513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [sessionId]);
|
||||
}, [isPromptSecurityAlternateScreenActive, sessionId]);
|
||||
|
||||
const executeSnippetCommand = useCallback(async (
|
||||
command: string,
|
||||
@@ -3538,6 +3591,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
|
||||
alternateScreen: isPromptSecurityAlternateScreenActive(),
|
||||
})
|
||||
&& broadcastThisSend
|
||||
&& onBroadcastInputRef.current
|
||||
@@ -3578,7 +3633,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
term.focus();
|
||||
}
|
||||
return true;
|
||||
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
|
||||
}, [isPromptSecurityAlternateScreenActive, prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
if (isScriptSnippet(snippet)) {
|
||||
@@ -3780,6 +3835,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
agentRecoveryStoppedRef.current = true;
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
@@ -3813,6 +3869,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleDisconnect = () => {
|
||||
if (attachExistingSession) return;
|
||||
if (statusRef.current === "disconnected") return;
|
||||
agentRecoveryStoppedRef.current = true;
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
@@ -4145,14 +4202,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startReconnectRef.current = startReconnect;
|
||||
|
||||
const handleRetry = () => {
|
||||
agentRecoveryStoppedRef.current = false;
|
||||
startReconnect("manual");
|
||||
};
|
||||
manualReconnectRequestRef.current = handleRetry;
|
||||
const canAgentReconnectRef = useRef<() => boolean>(() => false);
|
||||
canAgentReconnectRef.current = () => statusRef.current === 'disconnected'
|
||||
&& !agentRecoveryStoppedRef.current && !isAuthFailureMessage(error || '')
|
||||
&& !auth.needsAuth && !needsHostKeyVerification && !isConnectionAwaitingUserInput
|
||||
&& !autoReconnectLoopActiveRef.current && !reconnectWakeInFlightRef.current
|
||||
&& reconnectPreparationTokenRef.current === null;
|
||||
const readAgentRecoveryRef = useRef(() => ({ status: String(statusRef.current), waitingForInput: false, reconnecting: false, stopped: false }));
|
||||
readAgentRecoveryRef.current = () => ({
|
||||
status: statusRef.current,
|
||||
waitingForInput: Boolean(auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput || isAuthFailureMessage(error || '')),
|
||||
stopped: agentRecoveryStoppedRef.current,
|
||||
reconnecting: autoReconnectLoopActiveRef.current || reconnectWakeInFlightRef.current
|
||||
|| reconnectPreparationTokenRef.current !== null,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (attachExistingSession) return undefined;
|
||||
return terminalReconnectRegistry.register(
|
||||
sessionId,
|
||||
() => manualReconnectRequestRef.current(),
|
||||
() => canAgentReconnectRef.current(),
|
||||
() => readAgentRecoveryRef.current(),
|
||||
);
|
||||
}, [attachExistingSession, sessionId]);
|
||||
|
||||
@@ -4558,6 +4632,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onAutocompleteReposition: () => autocompleteRepositionRef.current?.(),
|
||||
terminalContextActionsRef,
|
||||
isRestoringSelectionRef,
|
||||
isPaneVisibleRef: isVisibleRef,
|
||||
};
|
||||
|
||||
const safeFitRef = useRef(safeFit);
|
||||
@@ -4815,7 +4890,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ isPromptSecurityAlternateScreenActive, CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setConnectionDeadlineAt, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -4828,7 +4903,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onDismiss={dismissScriptOverlay}
|
||||
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
||||
/>
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, connectionDeadlineAt, connectionTimeoutSeconds: CONNECTION_TIMEOUT / 1000, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
|
||||
<ScriptSaveRecordingDialog
|
||||
open={saveRecordingOpen}
|
||||
code={recordedCode}
|
||||
|
||||
@@ -218,7 +218,7 @@ test('an attached observe popup never owns automatic reconnect', () => {
|
||||
const reconnectEnd = terminalSource.indexOf('const prepareRestoredReconnect', reconnectStart);
|
||||
const reconnectSource = terminalSource.slice(reconnectStart, reconnectEnd);
|
||||
assert.match(reconnectSource, /if \(attachExistingSession\) return false;/);
|
||||
assert.match(reconnectSource, /\[attachExistingSession, host, t, terminalSettings, updateStatus\]/);
|
||||
assert.match(reconnectSource, /\[attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus\]/);
|
||||
assert.match(terminalSource, /const startReconnect = async[\s\S]*?if \(attachExistingSession\) return;/);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
+38
-17
@@ -4,7 +4,7 @@ import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '.
|
||||
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
|
||||
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
|
||||
import { useTrackShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
|
||||
import type { EditorTabChrome } from '../application/state/editorTabStore';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import {
|
||||
@@ -56,6 +56,11 @@ const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
|
||||
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
|
||||
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
|
||||
const emptyTabStyle: React.CSSProperties = {};
|
||||
// Hoisted so the memoized root tabs see a stable icon element every render.
|
||||
const VAULT_TAB_ICON = <FolderLock size={14} />;
|
||||
const SFTP_TAB_ICON = <Folder size={14} />;
|
||||
type WorkspaceTabSessions = { ids: string[]; sessions: TerminalSession[] };
|
||||
const EMPTY_WORKSPACE_TAB_SESSIONS: WorkspaceTabSessions = { ids: [], sessions: [] };
|
||||
|
||||
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
|
||||
return Math.max(0, hostTreeLayoutWidth - toggleRight);
|
||||
@@ -242,18 +247,19 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
: hotkeyScheme === 'pc'
|
||||
? switchTabKeyBinding?.pc ?? null
|
||||
: null;
|
||||
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
|
||||
// Badges only appear while the switch modifier is held. The held flag is a
|
||||
// small store read by each tab's TabIconSlot, so pressing Ctrl/⌘ does not
|
||||
// re-render the tab bar; the numbers themselves only change with the tabs.
|
||||
useTrackShortcutModifierHeld(showTabNumberBadges ? switchTabKey : null, hotkeyScheme);
|
||||
const tabShortcutNumbers = useMemo(() => {
|
||||
// Keep the tab bar quiet until the modifier for the active shortcut scheme
|
||||
// is held, while retaining the existing setting as the master toggle.
|
||||
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
|
||||
if (!showTabNumberBadges || hotkeyScheme === 'disabled') return null;
|
||||
return buildTabShortcutNumberById({
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
});
|
||||
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
|
||||
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, orderedTabs, editorTabs]);
|
||||
const isHostTreeOpen = useTerminalHostTreeOpen();
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
const toggleHostTree = useToggleTerminalHostTree();
|
||||
@@ -520,6 +526,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
isHostTreeOpen,
|
||||
]);
|
||||
|
||||
// Per-workspace session lists, rebuilt only when workspaces/sessions change
|
||||
// (not on every tab switch, drag frame or modifier change) so the memoized
|
||||
// WorkspaceTopTab keeps its props.
|
||||
const workspaceTabSessions = useMemo(() => {
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
return new Map(workspaces.map((workspace): [string, WorkspaceTabSessions] => {
|
||||
const ids = collectSessionIds(workspace.root);
|
||||
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
|
||||
// dynamic title updates stay live without remapping the whole bar.
|
||||
const workspaceSessions = ids
|
||||
.map((sessionId) => sessionById.get(sessionId))
|
||||
.filter((session): session is TerminalSession => Boolean(session));
|
||||
return [workspace.id, { ids, sessions: workspaceSessions }];
|
||||
}));
|
||||
}, [sessions, workspaces]);
|
||||
const handleDetachSessionFromWorkspace = useCallback(
|
||||
(_workspaceId: string, sessionId: string) => onRemoveSessionFromWorkspace(sessionId),
|
||||
[onRemoveSessionFromWorkspace],
|
||||
);
|
||||
|
||||
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
// Payload must be written on the original dragstart. A slow reorder calls
|
||||
// this again from `drag`, when DataTransfer is already read-only.
|
||||
@@ -929,20 +955,15 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
|
||||
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
|
||||
// dynamic title updates stay live without remapping the whole bar.
|
||||
const workspaceSessions = workspaceSessionIds
|
||||
.map((sessionId) => sessions.find((s) => s.id === sessionId))
|
||||
.filter((s): s is TerminalSession => Boolean(s));
|
||||
const workspaceSessionLists = workspaceTabSessions.get(workspace.id) ?? EMPTY_WORKSPACE_TAB_SESSIONS;
|
||||
|
||||
return (
|
||||
<WorkspaceTopTab
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
paneCount={paneCount}
|
||||
workspaceSessionIds={workspaceSessionIds}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceSessionIds={workspaceSessionLists.ids}
|
||||
workspaceSessions={workspaceSessionLists.sessions}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
isBeingDragged={isBeingDragged}
|
||||
isDraggingForReorder={isDraggingForReorder}
|
||||
@@ -958,7 +979,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace={onRenameWorkspace}
|
||||
onCopyWorkspace={onCopyWorkspace}
|
||||
onCloseWorkspace={onCloseWorkspace}
|
||||
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
|
||||
onDetachSessionFromWorkspace={handleDetachSessionFromWorkspace}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
@@ -1040,7 +1061,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<RootTopTab
|
||||
tabId="vault"
|
||||
label="Vaults"
|
||||
icon={<FolderLock size={14} />}
|
||||
icon={VAULT_TAB_ICON}
|
||||
className="rounded"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('vault')}
|
||||
@@ -1049,7 +1070,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<RootTopTab
|
||||
tabId="sftp"
|
||||
label="SFTP"
|
||||
icon={<Folder size={14} />}
|
||||
icon={SFTP_TAB_ICON}
|
||||
className="rounded-t-md"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('sftp')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import test from 'node:test';
|
||||
import test, { before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
@@ -6,6 +6,12 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ComposerModelPicker } from './ComposerModelPicker';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { loadLocaleMessages } from '../../application/i18n/messages';
|
||||
|
||||
// Non-English dictionaries load on demand; static rendering runs no effects.
|
||||
before(async () => {
|
||||
for (const locale of ['zh-CN', 'zh-TW', 'es', 'ru']) await loadLocaleMessages(locale);
|
||||
});
|
||||
|
||||
for (const [locale, cachedLabel] of [
|
||||
['en', 'Cached'],
|
||||
|
||||
@@ -173,7 +173,9 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
error instanceof Error && error.message.includes('VAULT_BACKUP_DECRYPT_FAILED')
|
||||
? t('cloudSync.localBackups.decryptFailed')
|
||||
: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
@@ -294,6 +296,10 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.folderWarning')}
|
||||
</p>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
|
||||
*/
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { saveEditorTab } from '../../application/state/editorTabSave';
|
||||
@@ -37,7 +37,7 @@ export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWid
|
||||
};
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
const TextEditorTabViewImpl: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
@@ -66,6 +66,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handlePopOut = useMemo(
|
||||
() => (onPopOut ? () => onPopOut(tabId) : undefined),
|
||||
[onPopOut, tabId],
|
||||
);
|
||||
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
@@ -126,10 +131,14 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
onPopOut={onPopOut ? () => onPopOut(tabId) : undefined}
|
||||
onPopOut={handlePopOut}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoized: AppView re-renders for unrelated shell state; an editor tab only
|
||||
// needs to render when its own props or subscribed tab state change.
|
||||
export const TextEditorTabView = React.memo(TextEditorTabViewImpl);
|
||||
|
||||
export default TextEditorTabView;
|
||||
@@ -4,9 +4,12 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import { loadLocaleMessages } from "../../application/i18n/messages.ts";
|
||||
import { NoteOutline } from "./NoteOutline.tsx";
|
||||
|
||||
test("note outline renders a borderless hierarchy without heading-level labels", () => {
|
||||
test("note outline renders a borderless hierarchy without heading-level labels", async () => {
|
||||
// Static rendering runs no effects, so load the dictionary explicitly.
|
||||
await loadLocaleMessages("zh-CN");
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<NoteOutline
|
||||
|
||||
@@ -4,10 +4,36 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
MAX_SOURCE_HISTORY_CHARS,
|
||||
MAX_SOURCE_HISTORY_STEPS,
|
||||
NoteSourceEditor,
|
||||
type NoteSourceEditorHandle,
|
||||
pushSourceHistory,
|
||||
} from "./NoteSourceEditor.tsx";
|
||||
|
||||
const snapshot = (value: string) => ({ value, selectionStart: 0, selectionEnd: 0 });
|
||||
|
||||
test("source undo history keeps only the most recent steps", () => {
|
||||
const stack: Array<ReturnType<typeof snapshot>> = [];
|
||||
for (let step = 0; step < MAX_SOURCE_HISTORY_STEPS + 50; step += 1) {
|
||||
pushSourceHistory(stack, snapshot(`v${step}`));
|
||||
}
|
||||
assert.equal(stack.length, MAX_SOURCE_HISTORY_STEPS);
|
||||
assert.equal(stack[0].value, "v50");
|
||||
assert.equal(stack.at(-1)?.value, `v${MAX_SOURCE_HISTORY_STEPS + 49}`);
|
||||
});
|
||||
|
||||
test("source undo history drops old full-note copies past the text budget", () => {
|
||||
const stack: Array<ReturnType<typeof snapshot>> = [];
|
||||
const third = Math.floor(MAX_SOURCE_HISTORY_CHARS / 3) + 1;
|
||||
for (const label of ["a", "b", "c", "d"]) pushSourceHistory(stack, snapshot(label.repeat(third)));
|
||||
assert.deepEqual(stack.map((entry) => entry.value[0]), ["c", "d"]);
|
||||
|
||||
// The newest step always survives, even when it alone is over budget.
|
||||
pushSourceHistory(stack, snapshot("e".repeat(MAX_SOURCE_HISTORY_CHARS + 1)));
|
||||
assert.deepEqual(stack.map((entry) => entry.value[0]), ["e"]);
|
||||
});
|
||||
|
||||
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
|
||||
@@ -9,6 +9,29 @@ interface SourceHistorySnapshot {
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
/** Undo steps kept per note; the oldest steps are dropped first. */
|
||||
export const MAX_SOURCE_HISTORY_STEPS = 200;
|
||||
/**
|
||||
* Every step holds a full copy of the note, so long sessions on a large note
|
||||
* grew without bound. Also cap the text the steps hold (~16 MB as UTF-16).
|
||||
*/
|
||||
export const MAX_SOURCE_HISTORY_CHARS = 8_000_000;
|
||||
|
||||
export const pushSourceHistory = <T extends { value: string }>(stack: T[], snapshot: T): void => {
|
||||
stack.push(snapshot);
|
||||
let chars = 0;
|
||||
for (const entry of stack) chars += entry.value.length;
|
||||
let drop = 0;
|
||||
while (
|
||||
stack.length - drop > 1
|
||||
&& (stack.length - drop > MAX_SOURCE_HISTORY_STEPS || chars > MAX_SOURCE_HISTORY_CHARS)
|
||||
) {
|
||||
chars -= stack[drop].value.length;
|
||||
drop += 1;
|
||||
}
|
||||
if (drop > 0) stack.splice(0, drop);
|
||||
};
|
||||
|
||||
export const shouldCoalesceSourceUndoStep = (
|
||||
previous: { inputType: string; at: number; caret: number } | null,
|
||||
inputType: string,
|
||||
@@ -173,7 +196,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
edit.start + edit.removedLength,
|
||||
);
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
pushSourceHistory(undoStackRef.current, baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
|
||||
@@ -189,7 +212,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
const previousSelectionStart = inputType === "deleteContentBackward"
|
||||
? edit.start + edit.removedLength
|
||||
: edit.start;
|
||||
undoStackRef.current.push(createSnapshot(
|
||||
pushSourceHistory(undoStackRef.current, createSnapshot(
|
||||
localValue,
|
||||
previousSelectionStart,
|
||||
edit.start + edit.removedLength,
|
||||
@@ -214,9 +237,9 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
compositionBaselineRef.current = null;
|
||||
const current = createSnapshot();
|
||||
if (action === "undo") {
|
||||
redoStackRef.current.push(current);
|
||||
pushSourceHistory(redoStackRef.current, current);
|
||||
} else {
|
||||
undoStackRef.current.push(current);
|
||||
pushSourceHistory(undoStackRef.current, current);
|
||||
}
|
||||
setLocalValue(target.value);
|
||||
onChange(target.value);
|
||||
@@ -260,7 +283,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
pushSourceHistory(undoStackRef.current, createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
@@ -287,7 +310,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
const end = textarea.selectionEnd;
|
||||
const result = wrapMarkdownSyntax(localValue, start, end, action);
|
||||
if (result.text !== localValue) {
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
pushSourceHistory(undoStackRef.current, createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
@@ -350,7 +373,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const baseline = createSnapshot();
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
pushSourceHistory(undoStackRef.current, baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
resetUserEditCoalescing();
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
} from '../../application/state/usePluginViewLifecycle';
|
||||
import { Button } from '../ui/button';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
|
||||
|
||||
export { requestOpenPluginView };
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
|
||||
|
||||
export function PluginContributionHost({
|
||||
@@ -28,7 +31,7 @@ export function PluginContributionHost({
|
||||
activeView,
|
||||
close,
|
||||
effectiveRequested,
|
||||
mountRef,
|
||||
viewQueryContext,
|
||||
} = usePluginViewLifecycle({
|
||||
locale,
|
||||
theme,
|
||||
@@ -38,18 +41,24 @@ export function PluginContributionHost({
|
||||
|
||||
if (!effectiveRequested || !activeView) return null;
|
||||
const location = activeView.view.location;
|
||||
const content = <PluginHostDrawnPage pluginId={activeView.plugin.id} viewId={activeView.view.id} version={activeView.plugin.version} slot={`view:${activeView.view.id}`} active context={viewQueryContext}
|
||||
subscribeHidden={subscribeNoop} unavailable={() => void close()} loadingLabel={t('plugins.vault.loading')} />;
|
||||
const containerClass = location === 'aside'
|
||||
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
|
||||
: location === 'tab'
|
||||
? 'absolute inset-0 z-40 flex p-2 pt-0'
|
||||
: location === 'panel'
|
||||
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
|
||||
: location === 'modal'
|
||||
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
|
||||
? 'fixed left-1/2 top-1/2 z-40 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
|
||||
: 'absolute inset-0 z-40 bg-background';
|
||||
|
||||
if (location === 'tab') {
|
||||
return (
|
||||
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
<section className={containerClass} role="region" aria-label={activeView.view.title}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm">
|
||||
{content}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +67,7 @@ export function PluginContributionHost({
|
||||
<section
|
||||
className={`${containerClass} flex flex-col`}
|
||||
role={location === 'modal' ? 'dialog' : 'region'}
|
||||
data-plugin-dialog={location === 'modal' ? 'true' : undefined}
|
||||
aria-modal={location === 'modal' ? true : undefined}
|
||||
aria-label={activeView.view.title}
|
||||
>
|
||||
@@ -80,7 +90,9 @@ export function PluginContributionHost({
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</header>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
|
||||
{content}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useHostDrawnPluginView } from '../../application/state/useHostDrawnPluginView';
|
||||
import { PluginSettingsEntry } from './PluginSettingsEntry';
|
||||
import { PluginRemoteSurface } from './PluginRemoteSurface';
|
||||
|
||||
export function PluginHostDrawnPage(props: Parameters<typeof useHostDrawnPluginView>[0] & { loadingLabel: string; pluginId: string }) {
|
||||
const view = useHostDrawnPluginView(props);
|
||||
if (!view.visible) return null;
|
||||
if (view.loading) return <p data-plugin-view-slot={props.slot.split(':')[0]} role="status" className="p-4 text-sm text-muted-foreground">{props.loadingLabel}</p>;
|
||||
return <div className="relative isolate h-full min-h-0 w-full flex-1" data-plugin-view-slot={props.slot.split(':')[0]}>
|
||||
<PluginRemoteSurface key={`${props.viewId}:${props.version}:${props.slot}`} tree={view.tree} css={view.css} settled={view.settled} loadImage={view.loadImage} onEvent={view.sendEvent} />
|
||||
<div className="absolute right-3 top-3 z-10" data-plugin-settings-entry="">
|
||||
<PluginSettingsEntry key={`${props.pluginId}:${props.version}`} pluginId={props.pluginId} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user