Compare commits

..
260 changed files with 931 additions and 15623 deletions

No files matched your search

+2 -1
View File
@@ -1202,7 +1202,8 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: *freeze_ai_helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
- name: Install Claude Code CLI
run: |
-67
View File
@@ -7,7 +7,6 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -399,69 +398,3 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
assert.equal(hosts[0].password, 'old-password');
assert.deepEqual(errors, ['Request not found']);
});
test('local host without explicit shell resolves the configured Settings shell', () => {
const connectedHosts: Host[] = [];
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
const discoveredShells = [
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
];
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session';
},
discoveredShells,
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
});
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
const connectedHosts: Host[] = [];
const localHost: Host = {
...baseHost,
id: 'local-2',
hostname: 'localhost',
protocol: 'local',
localShell: 'pwsh',
};
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session-2';
},
discoveredShells: [],
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'pwsh');
assert.equal(connectedHosts[0].localShellName, undefined);
});
-11
View File
@@ -8,7 +8,6 @@ import {
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { focusEditorInWindow } from '../state/editorWindowClient';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -72,16 +71,6 @@ export function AppActiveTabChrome({
}
}, [showSftpTab, activeTabId, setActiveTabId]);
useEffect(() => {
if (!isEditorTabId(activeTabId)) return;
const editorId = fromEditorTabId(activeTabId);
if (!editorId) return;
const tab = editorTabs.find((entry) => entry.id === editorId);
if (tab?.placement !== 'window') return;
void focusEditorInWindow(editorId);
setActiveTabId('vault');
}, [activeTabId, editorTabs, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
+5 -21
View File
@@ -4,7 +4,6 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -205,10 +204,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleConnectToHostImpl) instead of
// 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
username: resolvedAuth.username || 'root',
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -1108,15 +1104,11 @@ export function handleCreateLocalTerminalImpl(
}
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
{
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (effectiveHost.protocol === 'serial') {
@@ -1146,10 +1138,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleCreateLocalTerminalImpl)
// instead of 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
username: resolvedAuth.username || 'root',
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -1197,13 +1186,8 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
// Gates the protocol picker (legacy name kept for its existing wiring).
// Only prompt when Telnet is available but isn't the host's default protocol;
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
// serial hosts must always launch their own transport, never offer
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
// without an explicit protocol are legacy SSH entries.
const effective = resolveEffectiveHost(host);
const effectiveProtocol = effective.protocol ?? 'ssh';
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
}
}
+3 -14
View File
@@ -34,17 +34,6 @@ test('serial targets use the serial editor', () => {
);
});
test('local targets use the local shell editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({
mode: 'edit',
openedHost: host({ protocol: 'local' }),
requestId: 3,
}),
'local',
);
});
test('new and ssh targets use the standard editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
@@ -82,8 +71,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
assert.match(source, /\[&>\*\]:pointer-events-auto/);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
});
test('editor host panels share vault resize width persistence', () => {
@@ -92,7 +81,7 @@ test('editor host panels share vault resize width persistence', () => {
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
assert.match(source, /resizable:\s*true/);
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
});
test('editor stays mounted while another app surface is active', () => {
+3 -17
View File
@@ -5,7 +5,6 @@ import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useI18n } from '../i18n/I18nProvider';
import HostDetailsPanel from '../../components/HostDetailsPanel';
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
import { PortalContainerProvider } from '../../components/ui/portal-container';
import { resolveGroupDefaults } from '../../domain/groupConfig';
@@ -23,16 +22,14 @@ import type {
} from '../../types';
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
export function resolveWorkSurfaceHostEditorKind(
target: WorkSurfaceHostEditorTarget,
): WorkSurfaceHostEditorKind {
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
? 'serial'
: target.mode === 'edit' && target.openedHost.protocol === 'local'
? 'local'
: 'standard';
: 'standard';
}
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
@@ -163,18 +160,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
style={getAppHostEditorLayerStyle(surfaceVisible)}
>
<PortalContainerProvider container={portalContainer}>
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
<LocalShellHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
groups={groups}
onSave={onSave}
onCancel={onCancel}
layout="overlay"
className="pointer-events-auto"
{...hostPanelResizeProps}
/>
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
<SerialHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
@@ -1,35 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { createHostTerminalSession } from '../state/sessionFactories';
// Exercise the production application callback, including its async command
// resolution boundary, before the state hook snapshots the shell type.
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
assert.ok(callback);
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
for (const script of [false, true]) {
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
let session: ReturnType<typeof createHostTerminalSession> | undefined;
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
(value: unknown) => value,
() => script,
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
session = createHostTerminalSession('snippet-local', hosts[0]);
},
async () => 'echo hello',
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: 'cmd' },
resolveShellSetting: () => ({ command: 'cmd.exe' }),
}),
);
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
assert.equal(session?.protocol, 'local');
assert.equal(session?.localShell, 'cmd.exe');
assert.equal(session?.shellType, 'cmd');
});
}
+19 -40
View File
@@ -22,7 +22,6 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
import { installEditorWindowSourceListeners } from '../state/editorWindowClient';
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
import {
isPluginViewTabId,
@@ -38,7 +37,6 @@ import {
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import { useI18n } from '../i18n/I18nProvider';
import { matchesKeyBinding } from '../../domain/models';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
import { upsertKnownHost } from '../../domain/knownHosts';
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
@@ -54,7 +52,6 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -271,6 +268,22 @@ export function AppSideEffects() {
renameSessionInline,
} = sessionState;
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
// AppSideEffects only needs host/session maps for glue handlers and effects.
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts);
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
},
[runSnippet],
);
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
const editorTabs = useEditorTabChromeList();
@@ -628,8 +641,6 @@ export function AppSideEffects() {
workspaces,
});
useEffect(() => installEditorWindowSourceListeners(), []);
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
@@ -1352,32 +1363,8 @@ export function AppSideEffects() {
{ validProxyProfileIds: proxyProfileIdSet },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
// Local-shell vault hosts saved with "System default shell" carry no
// localShell; resolve the Settings → Terminal shell here at the shared
// effective-host boundary so every launch path (connect, tray/dock open,
// workspace creation/append, tunnels) uses the configured shell instead
// of silently falling back to the backend OS default shell.
return resolveHostDefaultLocalShell(effectiveHost, {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
},
[runSnippet, resolveEffectiveHost],
);
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
@@ -1408,17 +1395,14 @@ export function AppSideEffects() {
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
discoveredShells,
host,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1445,10 +1429,6 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1491,7 +1471,6 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
+4 -38
View File
@@ -2,9 +2,8 @@
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
@@ -37,7 +36,6 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
@@ -355,16 +353,14 @@ function AppViewInner({ domains }: AppViewProps) {
[proxyProfiles],
);
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
defaultLocalShell,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
@@ -424,23 +420,6 @@ function AppViewInner({ domains }: AppViewProps) {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
if (tab.placement === "window") {
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
const ok = Boolean(result?.success) && result?.cancelled !== true;
if (ok) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() === closingTabId) {
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
}
}
return ok;
}
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
@@ -452,7 +431,7 @@ function AppViewInner({ domains }: AppViewProps) {
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tabIsDirty(tab);
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
@@ -483,16 +462,6 @@ function AppViewInner({ domains }: AppViewProps) {
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handlePopOutEditorTab = useCallback(async (id: string) => {
const tab = editorTabStore.getTab(id);
if (!tab || tab.placement === "window") {
if (tab) void focusEditorInWindow(id);
return;
}
const host = hostById.get(tab.hostId);
await popOutEditorTab(id, host?.label);
}, [hostById]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -569,8 +538,6 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -821,7 +788,7 @@ function AppViewInner({ domains }: AppViewProps) {
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
{editorTabs.map((tab) => (
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
<LazyTextEditorTabView
@@ -830,7 +797,6 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
/>
</Suspense>
</LazyLoadBoundary>
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
@@ -1,47 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { editorTabStore } from "../state/editorTabStore.ts";
import { popOutEditorTab } from "../state/editorWindowClient.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
const editorId = "quit-during-editor-handoff";
t.after(async () => {
await act(async () => renderer?.unmount());
editorTabStore.close(editorId);
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
let query!: () => Promise<void>;
let accept!: (result: { success: boolean }) => void;
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
const reports: boolean[] = [];
t.mock.method(toast, "warning", () => {});
t.mock.method(netcattyBridge, "get", () => ({
openEditorWindow: () => receipt,
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
}));
function SourceWindow() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
const opening = popOutEditorTab(editorId);
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
await query();
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
accept({ success: true });
assert.equal(await opening, true);
assert.equal(editorTabStore.getTab(editorId)?.content, "");
await query();
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
});
-1
View File
@@ -357,7 +357,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// exactly the same outcome.
let hasDirty = false;
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (!hasDirty) {
-6
View File
@@ -522,18 +522,12 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.searchSessions': 'Search titles and recent content…',
'ai.chat.clearSessionSearch': 'Clear session search',
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
-1
View File
@@ -1123,7 +1123,6 @@ Highlight the focused split pane:
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
-7
View File
@@ -869,13 +869,6 @@ export const enTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.popOut': 'Open in editor window',
'sftp.editor.dock': 'Dock to main window',
'sftp.editor.focusWindow': 'Focus editor window',
'sftp.editor.close': 'Close',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No files open',
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
-16
View File
@@ -407,7 +407,6 @@ export const enVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transfer next',
'sftp.transferCenter.pauseAll': 'Pause all',
'sftp.transferCenter.resumeAll': 'Resume all',
'sftp.transferCenter.cancelAll': 'Cancel all',
'sftp.transferCenter.empty': 'No transfers in this category',
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
'sftp.transferCenter.hideBackground': 'Hide background transfers',
@@ -1018,21 +1017,6 @@ export const enVaultMessages: Messages = {
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Local shell (CMD) host entries
'localShell.subtitle': 'Local Shell',
'localShell.panel.title': 'New Local Shell',
'localShell.panel.title.edit': 'Edit Local Shell',
'localShell.field.label': 'Name',
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'System default shell',
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
'localShell.field.shellEmpty': 'No shells found',
'localShell.field.shellResolved': 'Runs {command}',
'localShell.field.startDir': 'Start directory',
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
'localShell.saveAndConnect': 'Save & Connect',
'hostDetails.plugin.title': 'Plugin connection',
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
-6
View File
@@ -513,18 +513,12 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
'ai.chat.jumpUntitled': '(mensaje vacío)',
'ai.chat.usedTools': 'Herramientas usadas: {n}',
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
-1
View File
@@ -1081,7 +1081,6 @@ Resalta el panel dividido enfocado:
'vault.tree.expandAll': 'Expandir todo',
'vault.tree.collapseAll': 'Contraer todo',
'vault.hosts.newHost': 'Nuevo host',
'vault.hosts.newLocalShell': 'Shell local (CMD)',
'vault.hosts.newGroup': 'Nuevo grupo',
'vault.hosts.import': 'Importar',
'vault.hosts.export': 'Exportar',
-7
View File
@@ -868,13 +868,6 @@ export const esTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Ajuste de línea',
'sftp.editor.maximize': 'Maximizar',
'sftp.editor.popOut': 'Abrir en ventana de editor',
'sftp.editor.dock': 'Anclar a la ventana principal',
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
'sftp.editor.close': 'Cerrar',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
'sftp.editor.discardChanges': 'Descartar',
-16
View File
@@ -407,7 +407,6 @@ export const esVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transferir a continuación',
'sftp.transferCenter.pauseAll': 'Pausar todas',
'sftp.transferCenter.resumeAll': 'Reanudar todas',
'sftp.transferCenter.cancelAll': 'Cancelar todas',
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
@@ -1008,21 +1007,6 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
'localShell.panel.title.edit': 'Editar shell local',
'localShell.field.label': 'Nombre',
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
'localShell.field.shellEmpty': 'No se encontraron shells',
'localShell.field.shellResolved': 'Ejecuta {command}',
'localShell.field.startDir': 'Directorio de inicio',
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
'localShell.saveAndConnect': 'Guardar y conectar',
'hostDetails.plugin.title': 'Conexión de plugin',
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
-6
View File
@@ -469,18 +469,12 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
-7
View File
@@ -876,13 +876,6 @@ export const ruTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Перенос строк',
'sftp.editor.maximize': 'Развернуть',
'sftp.editor.popOut': 'Открыть в окне редактора',
'sftp.editor.dock': 'Вернуть в главное окно',
'sftp.editor.focusWindow': 'Перейти к окну редактора',
'sftp.editor.close': 'Закрыть',
'sftp.editor.windowTitle': 'Редактор',
'sftp.editor.windowEmpty': 'Нет открытых файлов',
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
'sftp.editor.discardChanges': 'Отбросить',
-17
View File
@@ -102,7 +102,6 @@ export const ruVaultMessages: Messages = {
'vault.tree.expandAll': 'Развернуть все',
'vault.tree.collapseAll': 'Свернуть все',
'vault.hosts.newHost': 'Новый хост',
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
'vault.hosts.newGroup': 'Новая группа',
'vault.hosts.import': 'Импорт',
'vault.hosts.export': 'Экспорт',
@@ -446,7 +445,6 @@ export const ruVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Передать следующей',
'sftp.transferCenter.pauseAll': 'Приостановить все',
'sftp.transferCenter.resumeAll': 'Продолжить все',
'sftp.transferCenter.cancelAll': 'Отменить все',
'sftp.transferCenter.empty': 'В этой категории нет передач',
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
@@ -1052,21 +1050,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.addTag': 'Добавить тег...',
'hostDetails.createTag': 'Создать тег',
'hostDetails.createGroup': 'Создать группу',
// Записи локальной оболочки (CMD) в хранилище
'localShell.subtitle': 'Локальная оболочка',
'localShell.panel.title': 'Новая локальная оболочка',
'localShell.panel.title.edit': 'Изменить локальную оболочку',
'localShell.field.label': 'Название',
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
'localShell.field.shell': 'Оболочка',
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
'localShell.field.shellEmpty': 'Оболочки не найдены',
'localShell.field.shellResolved': 'Запускает {command}',
'localShell.field.startDir': 'Начальный каталог',
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
'localShell.saveAndConnect': 'Сохранить и открыть',
'hostDetails.plugin.title': 'Подключение плагина',
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
-6
View File
@@ -522,18 +522,12 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.searchSessions': '搜索标题和近期内容…',
'ai.chat.clearSessionSearch': '清空会话搜索',
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
-2
View File
@@ -740,7 +740,6 @@ export const zhCNCoreMessages: Messages = {
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newLocalShell': '本地终端 (CMD)',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
@@ -1052,7 +1051,6 @@ export const zhCNCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '优先传输',
'sftp.transferCenter.pauseAll': '全部暂停',
'sftp.transferCenter.resumeAll': '全部继续',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '当前分类没有传输任务',
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
'sftp.transferCenter.hideBackground': '隐藏后台任务',
@@ -968,13 +968,6 @@ export const zhCNTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在编辑器窗口打开',
'sftp.editor.dock': '停靠回主窗口',
'sftp.editor.focusWindow': '聚焦编辑器窗口',
'sftp.editor.close': '关闭',
'sftp.editor.windowTitle': '编辑器',
'sftp.editor.windowEmpty': '没有打开的文件',
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
'sftp.editor.unsavedTitle': '未保存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
'sftp.editor.discardChanges': '不保存',
-15
View File
@@ -390,21 +390,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// 本地终端(CMD)主机条目
'localShell.subtitle': '本地终端',
'localShell.panel.title': '新建本地终端',
'localShell.panel.title.edit': '编辑本地终端',
'localShell.field.label': '名称',
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系统默认 Shell',
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
'localShell.field.shellEmpty': '未发现可用 Shell',
'localShell.field.shellResolved': '启动 {command}',
'localShell.field.startDir': '启动目录',
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
'localShell.saveAndConnect': '保存并打开',
'hostDetails.plugin.title': '插件连接',
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
-6
View File
@@ -512,18 +512,12 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.searchSessions': '搜尋標題和近期內容…',
'ai.chat.clearSessionSearch': '清空會話搜尋',
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
-2
View File
@@ -742,7 +742,6 @@ export const zhTWCoreMessages: Messages = {
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新增主機',
'vault.hosts.newLocalShell': '本地終端 (CMD)',
'vault.hosts.newGroup': '新增群組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
@@ -1054,7 +1053,6 @@ export const zhTWCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '優先傳輸',
'sftp.transferCenter.pauseAll': '全部暫停',
'sftp.transferCenter.resumeAll': '全部繼續',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
'sftp.transferCenter.hideBackground': '隱藏背景工作',
@@ -968,13 +968,6 @@ export const zhTWTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自動換行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在編輯器視窗開啟',
'sftp.editor.dock': '停靠回主視窗',
'sftp.editor.focusWindow': '聚焦編輯器視窗',
'sftp.editor.close': '關閉',
'sftp.editor.windowTitle': '編輯器',
'sftp.editor.windowEmpty': '沒有開啟的檔案',
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
'sftp.editor.unsavedTitle': '未儲存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
'sftp.editor.discardChanges': '不儲存',
-15
View File
@@ -390,21 +390,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立群組',
// 本地終端(CMD)主機項目
'localShell.subtitle': '本地終端',
'localShell.panel.title': '新增本地終端',
'localShell.panel.title.edit': '編輯本地終端',
'localShell.field.label': '名稱',
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系統預設 Shell',
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
'localShell.field.shellEmpty': '未找到可用的 Shell',
'localShell.field.shellResolved': '啟動 {command}',
'localShell.field.startDir': '啟動目錄',
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
'localShell.saveAndConnect': '儲存並開啟',
'hostDetails.plugin.title': '外掛程式連線',
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
-109
View File
@@ -466,115 +466,6 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
}
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
// Just over the 8 KiB cap: the truncation marker must not make the degraded
// copy larger than the original, or the ladder could evict a session whose
// unmodified JSON would have fit the budget.
for (const oversize of [8193, 8266, 20000]) {
const session = makeSession('near-cap', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
}]);
const fullJson = JSON.stringify([session]);
const result = serializeSessionsForStorage([session], fullJson.length - 10);
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
assert.ok(
JSON.stringify(result.sessions).length < fullJson.length,
`degraded copy smaller than original for ${oversize}`,
);
assert.ok(content.includes('truncated'));
}
});
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'still-open chat',
timestamp: 2,
}]);
const toolBulk = makeSession('tool-bulk', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
}]);
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
// The bloated tool result must not evict the other visible chat.
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
assert.ok(result.json.length <= 100 * 1024);
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
assert.ok(persistedToolContent);
assert.ok(persistedToolContent.length <= 9 * 1024);
assert.ok(persistedToolContent.includes('truncated'));
assert.ok(persistedToolContent.startsWith('zzz'));
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
});
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'tool' as const,
content: '',
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(
persisted.map(session => session.id),
sessions.map(session => session.id),
);
// Degradation sheds only as many messages as the budget demands, so at
// least one chat keeps its full tool output while the persisted set stays
// complete. Assert completion first; truncation second.
const shedContents = persisted.map(
session => session.messages[0].toolResults?.[0].content ?? '',
);
assert.ok(shedContents.some(content => content.includes('truncated')));
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
+29 -79
View File
@@ -195,40 +195,12 @@ const RETRY_SESSIONS_JSON_BYTES = [
128 * 1024,
] as const;
/**
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
@@ -246,7 +218,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -267,22 +239,6 @@ function stripReasoningCiphertextFromMessage(message: AISession['messages'][numb
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
@@ -303,7 +259,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -354,11 +310,6 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
@@ -370,13 +321,13 @@ export function serializeSessionsForStorage(
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -388,7 +339,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
ciphertextMessages,
};
});
@@ -413,12 +364,11 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
@@ -428,27 +378,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
-109
View File
@@ -1,109 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { editorTabStore } from "../../application/state/editorTabStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const original = {
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(original, "window");
const before = editorTabStore.getTab(original.editorId);
t.after(() => editorTabStore.close(original.editorId));
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: async () => { focusCount++; return { success: true }; },
}));
const errors = t.mock.method(toast, "error", () => {});
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
});
const modal = { ...original, content: "modal edits" };
await act(async () => { ops[action](modal); });
assert.equal(ops.showTextEditor, true);
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
assert.equal(ops.textEditorContent, "original");
assert.equal(modal.content, "modal edits");
assert.equal(editorTabStore.getTab(original.editorId), before);
assert.equal(focusCount, 0);
assert.equal(errors.mock.callCount(), 1);
});
}
for (const focusSucceeds of [true, false]) {
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const detached = {
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(detached, "window");
const before = editorTabStore.getTab(detached.editorId);
t.after(() => editorTabStore.close(detached.editorId));
let finishFocus!: (result: { success: boolean }) => void;
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: () => { focusCount++; return focusing; },
}));
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
});
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
assert.equal(focusCount, 1);
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
await act(async () => { finishFocus({ success: focusSucceeds }); });
assert.equal(ops.showTextEditor, !focusSucceeds);
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
});
}
@@ -1,16 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
t.mock.method(Date, "now", () => 1234567890);
const [firstRenderer, secondRenderer] = await Promise.all([
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
]);
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
});
-14
View File
@@ -28,20 +28,6 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
...overrides,
});
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
const store = new EditorTabStore();
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
store._debugInsert(tab);
const id = store.promoteFromModal({
...makeTab(),
remotePath: "/etc//nginx/./nginx.conf",
});
assert.equal(id, tab.id);
assert.equal(store.getTab(id), tab);
assert.equal(store.isDirty(id), true);
assert.equal(store.getTabs().length, 1);
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
+5 -107
View File
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
@@ -24,7 +23,6 @@ export interface EditorTab {
sftpTabId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
@@ -34,16 +32,12 @@ export interface EditorTab {
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
placement?: EditorTabPlacement;
windowDirty?: boolean;
}
type Listener = () => void;
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
export const tabIsDirty = (tab: EditorTab): boolean =>
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
export class EditorTabStore {
private tabs: EditorTab[] = [];
@@ -85,82 +79,7 @@ export class EditorTabStore {
};
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && tabIsDirty(t);
};
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
this.patch(id, {
placement: "window",
windowDirty: dirty,
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
});
this.notifyStructural();
};
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
const tab = this.getTab(id);
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
this.patch(id, { windowDirty: dirty });
};
upsertFromSnapshot = (
snapshot: EditorWindowTabSnapshot,
placement: EditorTabPlacement = "tab",
): EditorTabId => {
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
const normalized = normalizePath(snapshot.remotePath);
const existing = existingById ?? this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
const next: Partial<EditorTab> = {
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: placement === "window" ? "" : snapshot.content,
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
placement,
windowDirty: placement === "window"
? snapshot.content !== snapshot.baselineContent
: false,
savingState: "idle",
saveError: null,
};
if (existing) {
this.patch(existing.id, next);
if (existing.placement !== placement) this.notifyStructural();
return existing.id;
}
const tab: EditorTab = {
id: snapshot.editorId || this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: next.content ?? "",
baselineContent: next.baselineContent ?? "",
wordWrap: snapshot.wordWrap,
viewState: (next.viewState ?? null) as EditorTab["viewState"],
savingState: "idle",
saveError: null,
placement,
windowDirty: next.windowDirty === true,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
return tab.id;
return !!t && t.content !== t.baselineContent;
};
updateContent = (
@@ -172,12 +91,6 @@ export class EditorTabStore {
};
markSaved = (id: EditorTabId, newBaseline: string) => {
const tab = this.getTab(id);
if (tab?.placement === "window") {
// Only the detached renderer has the current content. Wait for its dirty report.
this.patch(id, { savingState: "idle", saveError: null });
return;
}
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
@@ -261,26 +174,17 @@ export class EditorTabStore {
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
placement?: EditorTabPlacement;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
if (existing.placement === "window") {
if (snapshot.content !== snapshot.baselineContent) {
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
}
return existing.id;
}
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
placement: snapshot.placement ?? existing.placement ?? "tab",
windowDirty: false,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
@@ -300,8 +204,6 @@ export class EditorTabStore {
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
placement: snapshot.placement ?? "tab",
windowDirty: false,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
@@ -319,9 +221,9 @@ export class EditorTabStore {
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
for (const tab of matching) {
const dirty = tabIsDirty(tab);
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
@@ -430,11 +332,9 @@ export type EditorTabChrome = Pick<
| 'sessionId'
| 'sftpTabId'
| 'hostId'
| 'hostLabel'
| 'remotePath'
| 'fileName'
| 'languageId'
| 'placement'
>;
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
@@ -443,11 +343,9 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
placement: tab.placement ?? "tab",
});
export const useHasEditorTabForSessions = (
@@ -1,147 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
const snapshot = {
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file", fileName: "file", languageId: "plaintext",
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
};
for (const latestContent of ["v3", "v2", "v1"]) {
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let finish!: (result: { ok: boolean }) => void;
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
const reports: boolean[] = [];
t.mock.method(netcattyBridge, "get", () => ({
saveEditorWindowTab: (request: { content: string }) => {
assert.equal(request.content, "v2");
return pending;
},
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
}));
const saving = saveDetachedEditorTab(snapshot.editorId);
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
finish({ ok: true });
assert.deepEqual(await saving, { ok: true });
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
assert.deepEqual(reports, [latestContent !== "v2"]);
});
}
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot, "window");
t.after(() => {
editorTabStore.close(snapshot.editorId);
releaseEditorTabSaveCoordinator(snapshot.editorId);
});
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
const receipts: EditorWindowSaveResult[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
}));
t.after(installEditorWindowSourceListeners());
const saving = onSave({ ...snapshot, requestId: "request" });
onDirty({ editorId: snapshot.editorId, dirty: true });
finish();
await saving;
assert.equal(receipts[0]?.ok, true);
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
onDirty({ editorId: snapshot.editorId, dirty: false });
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
});
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
const opening = popOutEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted" });
assert.equal(await opening, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
}
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean; error?: string }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({
dockEditorWindowTab: (sent: typeof snapshot) => {
calls++;
assert.equal(sent.content, "v2");
return receipt;
},
}));
const docking = dockDetachedEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 1);
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
assert.equal((await docking).success, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
if (outcome !== "accepted") {
assert.equal(tab.saveError, "previous save failed");
editorTabStore.updateContent(snapshot.editorId, "v3", null);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
}
});
}
test("dock does not take over an in-progress save", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "saving");
t.after(() => editorTabStore.close(snapshot.editorId));
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 0);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
});
test("a closing destination rejects a popout without releasing source content", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
assert.equal(await popOutEditorTab(snapshot.editorId), false);
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, "tab");
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
-283
View File
@@ -1,283 +0,0 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { saveEditorTab } from "./editorTabSave";
import {
editorTabStore,
tabIsDirty,
type EditorTab,
type EditorTabId,
} from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import { activeTabStore, toEditorTabId } from "./activeTabStore";
export function toEditorWindowSnapshot(
tab: EditorTab,
hostLabel?: string,
): EditorWindowTabSnapshot {
return {
editorId: tab.id,
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: hostLabel || tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
content: tab.content,
baselineContent: tab.baselineContent,
wordWrap: tab.wordWrap,
viewState: tab.viewState,
};
}
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
const bridge = netcattyBridge.get();
if (!bridge?.openEditorWindow) return false;
const result = await bridge.openEditorWindow(snapshot);
return result?.success === true;
}
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
return result?.success === true;
}
export async function popOutEditorTab(
tabId: EditorTabId,
hostLabel?: string,
): Promise<boolean> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return false;
if (tab.placement === "window") {
return focusEditorInWindow(tabId);
}
const dirty = tabIsDirty(tab);
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
// Hide the source editor during transfer, but retain its only owned copy
// until the destination has acknowledged installing the tab.
editorTabStore.markDetached(tabId, dirty, false);
const ok = await openEditorInWindow(snapshot).catch(() => false);
if (!ok) {
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
return false;
}
editorTabStore.markDetached(tabId, dirty);
const activeId = activeTabStore.getActiveTabId();
if (activeId === toEditorTabId(tabId)) {
activeTabStore.setActiveTabId("vault");
}
return true;
}
export async function closeOwnedEditorWindowTabs(
owner: { sessionId?: string; sftpTabId?: string },
options?: { force?: boolean },
): Promise<{ cancelled: boolean; closedIds: string[] }> {
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
if (matching.length === 0) return { cancelled: false, closedIds: [] };
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: options?.force === true,
});
const closedIds = result?.closedIds ?? [];
for (const id of closedIds) editorTabStore.close(id);
if (!result || result.success === false) {
return { cancelled: options?.force !== true, closedIds };
}
if (result.cancelled !== true) {
for (const tab of matching) editorTabStore.close(tab.id);
}
return {
cancelled: result.cancelled === true,
closedIds,
};
}
export async function confirmCloseOwnedEditors(
owner: { sessionId?: string; sftpTabId?: string },
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> {
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
if (windowClose.cancelled) return false;
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
}
export function forceCloseOwnedEditors(owners: {
sessionIds?: readonly string[];
sftpTabIds?: readonly string[];
}): EditorTabId[] {
const matching = editorTabStore.getTabs().filter((tab) => {
if (tab.placement !== "window") return false;
if (owners.sessionIds?.includes(tab.sessionId)) return true;
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
return false;
});
if (matching.length > 0) {
void netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: true,
});
}
return editorTabStore.forceCloseByOwners(owners);
}
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
}
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
const current = editorTabStore.getTab(tabId);
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
editorTabStore.setSavingState(tabId, "saving");
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
editorId: current.id,
sessionId: current.sessionId,
sftpTabId: current.sftpTabId,
hostId: current.hostId,
remotePath: current.remotePath,
content: current.content,
});
if (result?.ok) {
editorTabStore.markSaved(tabId, current.content);
netcattyBridge.get()?.reportEditorWindowDirty?.({
editorId: tabId,
dirty: editorTabStore.isDirty(tabId),
});
return { ok: true };
}
const error = result?.error || "Save failed";
editorTabStore.setSavingState(tabId, "error", error);
return { ok: false, error };
}
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return { success: false, error: "Editor tab closed" };
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
// Keep the snapshot's owner read-only until receipt. Success stays busy
// until the caller removes this copy; failure restores its previous state.
editorTabStore.setSavingState(tabId, "saving");
let accepted = false;
try {
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
toEditorWindowSnapshot(tab, tab.hostLabel),
);
if (result?.success) {
accepted = true;
return { success: true };
}
return { success: false, error: result?.error || "Failed to dock editor tab" };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
} finally {
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
}
}
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
}
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
if (editorIds.length === 0) return;
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
}
export function subscribeEditorWindowCloseTabs(
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
): () => void {
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
}
export function reportEditorWindowCloseTabsResult(
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
): void {
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
}
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors(() => {
let hasDirty = false;
try {
hasDirty = cb() === true;
} catch {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch {
// ignore
}
});
}
export function installEditorWindowSourceListeners(): () => void {
const bridge = netcattyBridge.get();
if (!bridge) return () => {};
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
try {
const tab = editorTabStore.getTab(payload.editorId);
if (!tab) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: "Editor tab closed before save completed",
});
return;
}
if (tab.sessionId !== payload.sessionId) {
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
}
const ok = await saveEditorTab(payload.editorId, payload.content);
const latest = editorTabStore.getTab(payload.editorId);
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok,
liveConnectionId: latest?.sessionId,
error: ok ? undefined : (latest?.saveError || "Save failed"),
});
} catch (err) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Save failed",
});
}
}) ?? (() => {});
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
try {
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
activeTabStore.setActiveTabId(toEditorTabId(id));
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
} catch (err) {
bridge.reportEditorWindowDockResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Failed to dock",
});
}
}) ?? (() => {});
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
}) ?? (() => {});
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
for (const editorId of payload.editorIds ?? []) {
editorTabStore.close(editorId);
}
}) ?? (() => {});
return () => {
unsubSave();
unsubDock();
unsubDirty();
unsubClosed();
};
}
-45
View File
@@ -1,45 +0,0 @@
export type EditorTabPlacement = "tab" | "window";
export interface EditorWindowTabSnapshot {
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: unknown;
}
export interface EditorWindowSaveRequest {
requestId: string;
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
remotePath: string;
content: string;
}
export interface EditorWindowSaveResult {
requestId: string;
ok: boolean;
liveConnectionId?: string;
error?: string;
}
export interface EditorWindowCloseTabsRequest {
requestId: string;
editorIds: string[];
force?: boolean;
}
export interface EditorWindowCloseTabsResult {
requestId: string;
cancelled: boolean;
closedIds: string[];
}
@@ -8,20 +8,10 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events close the session tab only when auto-close is enabled", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
{ kind: "closeSession" },
);
});
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
// The fallback for callers that have not loaded the terminal setting yet:
// keep the tab so a jump host / bastion reaping a session (a clean exit)
// never silently deletes the user's scrollback.
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -67,19 +57,11 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -151,17 +133,6 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -179,25 +150,3 @@ test("exit messages without a reported code stay plain instead of showing a sent
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
// A terminal-worker exit broadcast carries the worker's own exit status
// (graceful drain = 0). It belongs to the infrastructure failure, not to
// any remote shell, so it must never read as "session closed (code 0)".
assert.equal(
formatSessionClosedExitMessage({
reason: "error",
exitCode: 0,
error: "Terminal worker exited with code 0",
}),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error" }),
"\r\n[session closed unexpectedly]",
);
});
@@ -4,10 +4,7 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -19,13 +16,6 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
// Infrastructure exit codes do not describe the remote shell's status.
if (evt?.reason === "error") {
return "\r\n[session closed unexpectedly]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -44,11 +34,7 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
// Keep-tab is the safe default: a caller that has not loaded the terminal
// setting yet must not silently delete a session tab. Remote graceful
// channel closes (e.g. a jump host reaping an idle session) are reported
// as a clean exit, so an unavailable setting must not re-enable closing.
autoCloseOnExit = false,
autoCloseOnExit = true,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -1,244 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHostTerminalSession } from "./sessionFactories.ts";
import { createLocalTerminalSession } from "./sessionFactories.ts";
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import type { Host } from "../../domain/models";
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
id: "local-vault-1",
label: "Project A CMD",
hostname: "localhost",
username: "",
group: "Projects/A",
tags: ["local"],
os: "linux",
protocol: "local",
createdAt: 0,
...overrides,
});
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
const host = localVaultHost({
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/work"],
localShellName: "Ubuntu (WSL)",
localShellIcon: "ubuntu",
localStartDir: "/work",
});
const session = createHostTerminalSession("session-1", host);
assert.equal(session.protocol, "local");
assert.equal(session.hostId, "local-vault-1");
assert.equal(session.localShell, "wsl-ubuntu");
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
assert.equal(session.localShellName, "Ubuntu (WSL)");
assert.equal(session.localShellIcon, "ubuntu");
assert.equal(session.localStartDir, "/work");
});
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
const host: Host = {
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
} as Host;
const session = createHostTerminalSession("session-2", host);
assert.equal(session.protocol, "ssh");
assert.equal(session.localShell, undefined);
assert.equal(session.localShellName, undefined);
assert.equal(session.shellType, undefined);
});
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
assert.equal(cmd.shellType, "cmd");
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
assert.equal(pwsh.shellType, "powershell");
// Discovered shell IDs persisted by the local-shell editor are not
// executable basenames, but they still resolve to POSIX shells at spawn
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
assert.equal(wslId.shellType, "posix");
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
assert.equal(gitBash.shellType, "posix");
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
assert.equal(cygwin.shellType, "posix");
// Unix discovery emits disambiguated path-slug IDs when multiple
// installations share a basename (e.g. /bin/bash vs
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
// the session must classify as that command's shell type, not "unknown".
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
assert.equal(slugBash.shellType, "posix");
const slugHomebrewBash = createHostTerminalSession(
"session-slug-homebrew",
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
);
assert.equal(slugHomebrewBash.shellType, "posix");
const customPowershellPath = createHostTerminalSession(
"session-pwsh-path",
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
);
assert.equal(customPowershellPath.shellType, "powershell");
// Default-shell hosts (empty localShell) launch the machine's OS default
// shell (the backend's own detection), so classification must use the
// runtime platform — not the saved host.os, which may describe a machine
// the host was synced/imported from. Node 22's navigator.userAgent is
// always "Node.js/<version>", so this test environment always classifies
// as POSIX regardless of the CI runner's real OS.
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
assert.equal(windowsSyncedDefault.shellType, "posix");
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
assert.equal(posixDefault.shellType, "posix");
const unknownCustom = createHostTerminalSession(
"session-unknown",
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
);
assert.equal(unknownCustom.shellType, "unknown");
});
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
assert.equal(session.hostId, "local-terminal");
assert.equal(session.localShellName, "CMD");
});
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
// Legacy vault hosts can predate localShellArgs, so the field may be
// absent on the Host; the session snapshot must still distinguish
// "launched with no args" ([]) from "no metadata" (undefined).
const host = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-noargs", host);
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
assert.deepEqual(session.localShellArgs, []);
});
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
const launchTimeHost = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-1", launchTimeHost);
// The saved host was later edited to a shell with args; the snapshot taken
// at launch time must win so the clone relaunches CMD, not WSL.
const editedHost: Host = {
...launchTimeHost,
label: "Edited WSL",
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/elsewhere"],
localShellName: "Ubuntu (WSL)",
localStartDir: "/elsewhere",
};
const resolved = resolveTerminalSessionHost({
session,
hosts: [editedHost],
groupConfigs: [],
proxyProfiles: [],
localOs: "windows",
});
assert.equal(resolved.localShell, "cmd");
assert.deepEqual(resolved.localShellArgs, []);
assert.equal(resolved.localShellName, "Command Prompt");
});
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
// The saved host has no directory of its own, so the starter launches in
// Settings > Terminal's localStartDir. That effective directory has to be
// recorded now; overlay falls back to the host or the current setting when
// the session field is missing.
const saved = localVaultHost({
localShell: "bash",
localShellName: "Bash",
localStartDir: "",
});
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
const session = createHostTerminalSession("session-inherit", effective);
assert.equal(session.localStartDir, "/home/alice/global");
const changedSettings = {
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
};
const afterSettingsChange = resolveTerminalSessionHost({
session,
hosts: [saved],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
const afterHostEdit = resolveTerminalSessionHost({
session,
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
});
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
const saved = localVaultHost();
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
});
const session = createHostTerminalSession("session-default-shell", effective);
assert.equal(session.localShell, "/bin/bash");
assert.deepEqual(session.localShellArgs, ["-l"]);
assert.equal(session.localStartDir, "/home/alice/global");
});
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedExplicit.localStartDir, "/work");
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedSsh.localStartDir, undefined);
assert.equal(resolvedSsh, ssh);
});
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
const saved = localVaultHost({ username: "" });
const ordinary = createLocalTerminalSession("ordinary-local");
const opened = createHostTerminalSession("saved-local", saved);
assert.equal(opened.username, ordinary.username);
assert.equal(opened.username, "local");
assert.equal(saved.username, "");
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
});
-47
View File
@@ -1,5 +1,4 @@
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
@@ -19,28 +18,6 @@ export interface LocalTerminalOptions {
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
/**
* [] captures the absence of custom arguments instead of inheriting them
* from a later host edit. The starter still applies backend defaults to []
* and undefined alike. Share the array to preserve pane equality checks.
*/
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
/**
* Platform string describing the machine that is launching the session.
*
* Vault local hosts can be synced/imported from another OS, so their saved
* `host.os` describes the source machine. When the host has no explicit
* shell (`localShell` empty), `startLocalSession` spawns the current
* machine's OS default shell, so classification must fall back to the
* runtime platform — not the persisted host OS — or a Windows host opened
* on Linux would be tagged "powershell" while Bash actually runs.
*/
const runtimePlatformLike = (): string | undefined => {
if (typeof navigator === "undefined") return undefined;
return navigator.userAgent || navigator.platform || undefined;
};
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
@@ -136,30 +113,6 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
// Local-shell vault hosts carry the per-host shell selection into the
// session (the bridge resolves discovered shell ids at spawn time).
// shellType matches ordinary local-terminal creation so captured-CWD
// restore can reject POSIX injection into CMD/PowerShell sessions.
...(host.protocol === "local" ? {
username: "local",
// With an explicit shell the basename classifies it on any OS, so the
// saved host.os stays the fallback; a default-shell host launches the
// current machine's OS default, so classify against the runtime.
shellType: classifyLocalShellType(
host.localShell,
host.localShell?.trim() ? host.os : runtimePlatformLike(),
),
localShell: host.localShell ?? "",
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName,
localShellIcon: host.localShellIcon,
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
localStartDir: host.localStartDir || "~",
} : {}),
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
@@ -1,790 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectSessionSearchFields,
type SessionHistorySearchMessage,
} from "../../domain/sessionHistorySearch.ts";
import {
PINYIN_CACHE_MAX_CHARS,
getPinyinCacheStatsForTests,
resetPinyinCacheForTests,
} from "../../lib/searchMatcher.ts";
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
// One instance for the whole file: it keeps the pre-factory semantics of every
// test sharing (and clearing) a single cache.
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
// Fixture messages stay loosely typed: persisted sessions carry far richer
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
// and extra properties must remain structurally harmless.
function createSession(
id: string,
title: string,
messages: Array<Record<string, unknown>>,
) {
return {
id,
title,
messages: messages.map((message, index) => ({
id: `${id}-m${index}`,
role: "user",
content: "",
timestamp: index,
...message,
})) as SessionHistorySearchMessage[],
};
}
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
const sessions = [
createSession("a", "nginx restart", []),
createSession("b", "disk cleanup", []),
];
for (const query of ["", " \t\n"]) {
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
}
});
test("filterSessionHistory handles special characters through the shared matcher", () => {
const sessions = [
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
createSession("b", "Deploy", [{ content: "ship it" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
});
test("filterSessionHistory matches message content of user and assistant turns", () => {
const sessions = [
createSession("a", "Debugging", [
{ role: "user", content: "why is sshd down on prod-3?" },
{ role: "assistant", content: "let me check the journal" },
]),
createSession("b", "Deploy notes", [
{ role: "user", content: "ship release 1.2" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
});
test("filterSessionHistory ignores hidden system messages", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "all clear" },
]),
createSession("b", "Deploy", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "ship it" },
]),
];
// A query found only inside system messages must not surface the sessions:
// the reopened conversation hides those messages, so a match would display
// no matching text.
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
});
test("filterSessionHistory searches thinking and tool call/result content", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", thinking: "the certificate expired" },
]),
createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
});
test("filterSessionHistory matches text stored only in tool call arguments", () => {
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx" },
}],
},
]),
createSession("b", "Deploy", [
{
role: "assistant",
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
});
test("filterSessionHistory matches persisted agent activities", () => {
const sessions = [
createSession("a", "Research", [
{
role: "assistant",
agentActivities: [
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
],
},
]),
createSession("b", "Refactor", [
{
role: "assistant",
agentActivities: [
{
id: "2",
type: "file_change",
status: "completed",
changes: [
{ path: "src/sessionStore.ts", kind: "update" },
{ path: "src/sessionStore.test.ts", kind: "add" },
],
},
],
},
]),
createSession("c", "Plan", [
{
role: "assistant",
agentActivities: [
{
id: "3",
type: "plan_update",
status: "completed",
items: [
{ text: "extract search collector", completed: true },
{ text: "wire into drawer", completed: false },
],
},
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[2]);
assert.ok(fields.some((field) => field.includes("extract search collector")));
assert.ok(fields.some((field) => field.includes("rate limit hit")));
});
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
const sessions = [
createSession("a", "Untitled", [
{
role: "assistant",
content: "",
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
},
]),
createSession("b", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
],
},
]),
createSession("c", "Legacy", [
{
role: "user",
content: "see attached",
images: [
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
],
},
]),
createSession("d", "Payloads", [
{
role: "user",
content: "",
attachments: [
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
// Attachment base64 payloads and unlabeled attachments are not indexed.
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[1]);
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
assert.ok(!fields.some((field) => field === "AAAA"));
});
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
// One assistant message bearing enough tool calls (each argument capped at
// 2,000 chars) to exhaust the remaining session budget: iteration must be
// newest-first so the latest calls (appended at the array end) stay
// searchable instead of the oldest ones winning the budget.
const session = createSession("a", "Ops", [
{
role: "assistant",
content: "y".repeat(20_000),
toolCalls: Array.from({ length: 25 }, (_, i) => ({
name: `call-${i}`,
id: `t${i}`,
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
})),
},
]);
const fields = collectSessionSearchFields(session);
// The newest call (index 24, appended last) stays searchable along with the
// rest of the recent burst; the oldest call is the one dropped.
assert.ok(fields.some((field) => field.includes("marker-args-24")));
assert.ok(fields.some((field) => field.includes("marker-args-19")));
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
// Retained call fields are output in chronological order after the
// newest-first collection is reversed back (call-10 before call-19).
const call10 = fields.findIndex((field) => field.includes("call-10"));
const call19 = fields.findIndex((field) => field.includes("call-19"));
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
});
test("later attachment labels stay searchable when the label list overflows the cap", () => {
// The joined labels of these attachments exceed the 2,000-character field
// cap; feeding labels individually keeps the later (newest) ones findable.
const session = createSession("a", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
],
},
]);
const fields = collectSessionSearchFields(session);
assert.ok(fields.some((field) => field.includes("latest-run.log")));
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
});
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
const session = createSession("a", " ", [
{ role: "user", content: " " },
{ role: "assistant", content: "x".repeat(25_000) },
]);
const fields = collectSessionSearchFields(session);
assert.equal(fields.length, 1);
assert.equal(fields[0].length, 20_000);
});
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
const sessions = [
createSession("a", "重启生产服务器", []),
createSession("b", "backup", []),
];
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
});
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
// An empty persisted title is displayed as the localized "Untitled" label;
// searching for that label must find the session instead of hiding it.
const sessions = [
createSession("a", "", [{ content: "nginx notes" }]),
createSession("b", "nginx restart", []),
];
// Without the fallback the blank title contributes nothing.
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
// Titled sessions are unaffected by the fallback label.
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
// Localized (CJK) fallback labels match through title pinyin too.
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
const fields = collectSessionSearchFields(sessions[0], "Untitled");
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
});
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
const sessions = [
createSession("a", "重启服务器", [
{ role: "user", content: "restarted nginx without errors" },
]),
];
const combined = filterSessionHistory(sessions, "chongqi nginx");
assert.deepEqual(combined, [sessions[0]]);
// Message-only pinyin still stays out of the expensive fallback.
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(
filterSessionHistory(sessions, "failing nginx"),
[],
);
});
test("search field trimming stays inside the field and remaining session limits", () => {
const fieldLimited = createSession("a", "Ops", [
{ content: " ".repeat(20_000) + "outside-field" },
{ content: " recent-match " + " ".repeat(1_000_000) },
]);
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
// Title + three full fields leave 3,997 characters for the oldest message.
// Trimming must not reach past that window to discover more content.
const sessionLimited = createSession("b", "Ops", [
{ content: " ".repeat(3_997) + "outside-session" },
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
]);
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
});
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "user", content: "重启生产服务器" },
]),
];
// Pinyin transliteration of long message content is skipped; literal CJK
// queries still match message text.
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
});
test("collectSessionSearchFields bounds the total searchable text per session", () => {
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
role: "user" as const,
content: `chunk-${i}-${"y".repeat(19_000)}`,
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
// Title is indexed first so it always lands in the bounded window.
assert.equal(fields[0], "big session");
});
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
// allocated newest-first so the most recent message remains searchable.
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
role: "user" as const,
content: `${"x".repeat(19_900)} marker-${i}`,
})));
const fields = collectSessionSearchFields(session);
assert.equal(fields[0], "big session");
// Oldest messages are dropped, the newest is retained.
assert.ok(!fields.some((field) => field.includes("marker-0")));
assert.ok(fields.some((field) => field.includes("marker-3")));
// Output order stays chronological (marker-1 before marker-2).
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
});
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
// collected (oldest) message must be truncated to exactly the remaining
// budget instead of being discarded wholesale.
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
role: "user" as const,
content: "a".repeat(19_000),
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
assert.equal(total, 64_000);
// The oldest message fills the leftover budget with its freshest-available
// head instead of being dropped entirely.
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
assert.ok(fields.some((field) => field.startsWith("aaa")));
});
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
const blob = "x".repeat(5_000_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
// content); serialization must stop at the field cap instead of
// stringifying the whole argument object on every keystroke.
name: "sftp_write_file",
id: "t1",
arguments: { path: "/tmp/report.md", content: blob },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
assert.ok(serialized);
// The serialized field (plus the tool name) must land under the total
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
assert.ok(serialized!.length <= 2_000);
assert.ok(serialized!.includes("/tmp/report.md"));
// Short payloads still serialize fully and stay searchable.
const searchable = createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t2",
arguments: { command: "systemctl restart nginx" },
}],
},
]);
assert.ok(
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
);
});
test("wide tool call argument objects and long property names stay within the cap", () => {
// A wide object: thousands of own properties must be iterated lazily
// instead of materializing the full entries array on every keystroke.
const wide: Record<string, unknown> = {};
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
// An unusually large property name must be capped before it is escaped.
const longKey = "k".repeat(50_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
assert.ok(serialized);
assert.ok(serialized!.length <= 2_000);
// The oversized key (and the wide object past the cap) never materialize:
// the long key is truncated before escaping.
assert.ok(!fields.some((field) => field.length > 2_000));
});
test("filterSessionHistory matches persisted status text", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
]),
createSession("b", "Deploy", [
{ role: "user", content: "ship it" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
});
// Regression: bounds enforcement must run once per scan, not on every cache
// miss. Evicting per miss makes each rebuild drop the index of a later
// session in the same scan, so any history longer than the entry cap
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
const sessionCount = 80;
const sessions = Array.from({ length: sessionCount }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
// Observe corpus (re)builds: a cache hit serves the prepared haystack
// without touching `messages`, while a rebuild collects the fields again.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const second = filterSessionHistory(sessions, "deploy");
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
// every session retained by the last prune are cache hits. Per-miss
// eviction would have rebuilt all 80 sessions here.
const rebuilt = readSessions.size;
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
assert.deepEqual(second, sessions);
// Eviction must retain the FRONT of the ranked list (the sessions the next
// scan visits first) and rebuild only the oldest tail above the entry cap —
// evicting the Map's first key would drop the head and thrash it instead.
const rebuiltIds = [...readSessions].sort();
assert.deepEqual(
rebuiltIds,
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
);
});
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
// live sessions froze the cache's membership. New sessions are prepended to
// the ranked list, so every subsequently created conversation (and its
// immutable streaming updates) would rebuild its index on every keystroke
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
// could not correct it because the cache was already within both bounds.
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the thrash regression test above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted; the prune evicts the lowest-ranked tail instead.
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const third = filterSessionHistory(sessions, "deploy");
assert.deepEqual(third, sessions);
// The fresh, highest-ranked session stays cached; only the evicted tail
// above the entry cap is rebuilt.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: the entry cap must be enforced at cache-admission time, not only
// by the end-of-scan prune. When misses are small (empty/short searchable
// content), the char budget consumes almost no headroom per insert, so
// overshooting the entry cap mid-scan and letting the prune trim it made a
// single keystroke over a very long history build and transiently retain one
// index + Map entry per live session. Admission must instead evict the stale
// lowest-ranked untouched tail to make room (the same entry the prune would
// otherwise drop moments later), keeping the cache within the entry cap
// throughout the scan.
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// The single scan over the prepended list must admit "fresh" by evicting
// the stalest cached tail entry ("s63") during the scan: under the old
// overshoot-then-prune behavior "s63" would stay cached until the prune,
// so it would not be rebuilt in this same scan at all. Rebuilding it here
// is the (bounded, one-entry) cost of never exceeding the entry cap
// mid-scan.
filterSessionHistory(sessions, "deploy");
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
// Recovery is unchanged: the fresh session stays cached, and only the
// tail displaced below the entry cap rebuilds on the next keystroke.
readSessions.clear();
const next = filterSessionHistory(sessions, "deploy");
assert.deepEqual(next, sessions);
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
// 64,000-character corpus cap) already consuming nearly the 4M-character
// cache budget, a newly prepended high-ranked session was rejected without
// evicting anything, and the end-of-scan prune — seeing a cache within both
// bounds — kept the stale lower-ranked entries: the new large conversation
// then rebuilt its index on every search keystroke.
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
// index retaining ~192K chars (normalized fields + haystack + compact
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
const createLargeSession = (id: string) =>
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
filterSessionHistory(initial, "x");
// A newly created large session is prepended: it ranks above every cached
// entry, and its index can only fit if the stale tail is evicted.
const created = createLargeSession("fresh");
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted by evicting the stalest cached tail entry.
filterSessionHistory(sessions, "x");
readSessions.clear();
const second = filterSessionHistory(sessions, "x");
assert.deepEqual(second, sessions);
// The fresh, highest-ranked session stays cached; only the evicted stalest
// tail session (which no longer fits the char budget) rebuilds.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s19"]);
// And the steady state holds on subsequent scans too.
readSessions.clear();
filterSessionHistory(sessions, "x");
assert.deepEqual([...readSessions], ["s19"]);
});
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
filterSessionHistory([kept, removed], "nginx");
// Deleting a session then clearing the query goes through the blank path,
// which must prune the cache against the remaining list.
filterSessionHistory([kept], "");
// Subsequent searches are unaffected: the surviving session stays cached
// (its cached index is still there) and matching still works.
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
// The removed session's index is gone entirely (no match via a stale cache).
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
});
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
filterSessionHistory([session], "nginx");
clearSessionHistorySearchCache();
// After the clear, the next search rebuilds from scratch and still matches.
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
assert.deepEqual(filterSessionHistory([session], ""), [session]);
});
test("independent search instances own isolated caches", () => {
const first = createSessionHistorySearch();
const second = createSessionHistorySearch();
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
// Clearing one instance must not disturb the other's state: both stay fully
// functional afterwards (each store is private to its instance).
second.clearSessionHistorySearchCache();
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
});
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
// A pasted multi-megabyte query must not be re-normalized per session: it is
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
// input and normalized once per scan, so matching falls back to the capped
// prefix (which still spans tokens here).
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
// The uncapped-looking but normal query still matches exactly.
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
});
// Keep the author's long-title rescan protection local to history. Shared
// search must still match complete fields, including later joined fields.
test("history pinyin cache preserves long titles across same-order rescans", () => {
resetPinyinCacheForTests();
const sessions = Array.from({ length: 17 }, (_, index) =>
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
for (let pass = 0; pass < 2; pass += 1) {
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
}
const stats = getPinyinCacheStatsForTests();
assert.equal(stats.size, sessions.length);
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
resetPinyinCacheForTests();
});
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
const sessions = [
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
});
-382
View File
@@ -1,382 +0,0 @@
import {
getSessionSearchTitle,
matchesSessionSearchIndex,
prepareSessionSearchIndex,
type SessionHistorySearchTarget,
type SessionSearchIndex,
} from '../../domain/sessionHistorySearch';
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
/**
* Memory bounds for the per-session search index cache. `filterSessionHistory`
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
* haystack per keystroke scans tens of megabytes and freezes the renderer.
*
* The raw collected fields are NOT retained (only the prepared search fields),
* and
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
* unbounded cache would let one entry per live session (several normalized
* copies of each session's searchable corpus) permanently pin hundreds of
* megabytes in a long-running process. Both bounds hold after every scan's
* prune and are enforced eagerly on every insert (see
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
* in-flight scan has not yet visited — they rank below every visited session,
* so this can never cycle-evict an entry the same scan still needs); if no
* such entry remains, the insert is skipped — the miss is then rebuilt on the
* next search, exactly what the end-of-scan prune would have done to it.
* Skipping is only reachable for misses ranked below every cached entry, so
* cache membership never freezes for freshly prepended high-ranked sessions:
* at their (earliest) visit nothing is touched yet and the stale tail yields.
*/
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
/**
* Mutable cache state owned by exactly one search client (one mounted history
* drawer). Keeping this out of module scope means several concurrently mounted
* drawers (one per terminal tab) never share a cache: each drawer's prune only
* drops entries no longer in that drawer's scoped session list, and one
* drawer's unmount releases only its own store (previously a shared
* module-level cache was discarded for every drawer on any unmount and pruned
* against whichever drawer scanned last).
*/
export type SessionHistorySearchStore = {
/**
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
* object identity is a reliable cache key: each session's normalized/compact
* haystacks are built once and reused until that session's object changes.
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
*/
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
/**
* Session keys the in-flight `filterSessionHistory` scan has already visited
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
* never evicts a touched key: it either ranks above the current insert (it was
* visited earlier in this newest-first scan) or it is the current session
* itself (whose stale entry a rebuild is about to replace), and evicting either
* would cycle-evict entries the same scan still needs.
*/
scanTouched: Set<SessionHistorySearchTarget>;
/**
* Running total of the cache's retained characters. Maintained at every
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
* O(1) per miss instead of re-summing the whole cache on every keystroke
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
* across a scan).
*/
cacheChars: number;
};
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
function indexRetainedChars(index: SessionSearchIndex): number {
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
// from `haystack`), so their combined length approximates the cache's
// per-entry memory cost. Counting only the joined haystacks would
// undercount by roughly a full copy of the corpus and let the char budget
// retain ~50% more than advertised.
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
for (const field of index.prepared.normalizedFields) total += field.length;
return total;
}
/**
* Whether a freshly built index may be inserted into the cache without
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
* that re-enforces them runs once per scan, so admitting every prepared index
* would let a single search over hundreds/thousands of uncached sessions
* transiently pin the whole corpus (several normalized haystacks per session)
* or hold one index and Map entry per live session — for empty/short sessions
* the char budget consumes little room, so the entry cap would be enforced
* only after the complete scan — before pruning, briefly allocating memory
* for the whole scanned list.
*
* Rather than rejecting inserts outright (which froze cache membership the
* same way a hard entry-count gate would), lack of headroom in either bound
* first evicts the cache's stale tail — the lowest-ranked entries this scan
* has not yet visited. Because the scan visits the ranked list newest-first,
* every untouched entry ranks below the current insert, so evicting any of
* them never drops an index a later visit of the same scan still needs (no
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
* if no untouched entry remains does the insert get skipped — that is
* reachable only for misses ranked below every cached entry, which the
* end-of-scan prune would have evicted from the Map's back anyway, so the
* skip changes no steady-state membership: the index is simply rebuilt on
* the next search.
*
* The bounds check is applied to NEW entries only: cache hits merely
* re-append an existing entry (no growth), and a rebuild replaces the same
* session's entry (no entry-count growth; the char delta against the
* replaced index is checked).
*/
function canCacheSessionSearchIndex(
store: SessionHistorySearchStore,
previous: SessionSearchIndex | undefined,
index: SessionSearchIndex,
): boolean {
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
// A rebuild replaces its own entry (no entry-count growth); a new entry
// grows the Map by one.
const growsEntries = !previous;
if (
deltaChars <= 0
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
if (
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
// the Map's order is old-insertion-order front to back with this scan's
// touched keys re-appended at the end, so scanning the keys back-to-front
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
// entries first.
let entriesToFree = growsEntries
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
: 0;
const keysInInsertionOrder = [...store.cache.keys()];
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
if (entriesToFree <= 0
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
break;
}
const key = keysInInsertionOrder[i];
if (store.scanTouched.has(key)) continue;
const evicted = store.cache.get(key);
if (!evicted) continue;
store.cacheChars -= indexRetainedChars(evicted);
store.cache.delete(key);
if (growsEntries) entriesToFree--;
}
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
}
function getSessionSearchIndex(
store: SessionHistorySearchStore,
session: SessionHistorySearchTarget,
untitledLabel: string,
): SessionSearchIndex {
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
const cached = store.cache.get(session);
if (cached && cached.displayTitle === indexedTitle) {
// Re-append on every visit so the cache's insertion order mirrors the
// latest scan's visitation order (the ranked, newest-first session list);
// `pruneSessionSearchIndexCache` relies on that order when evicting.
store.cache.delete(session);
store.cache.set(session, cached);
store.scanTouched.add(session);
return cached;
}
const index = prepareSessionSearchIndex(session, untitledLabel);
// Mark the session touched before the gate so the gate's admission eviction
// can never pick this session's own (stale) entry — the rebuild below is
// about to replace it, and evicting it would corrupt the running char total.
store.scanTouched.add(session);
if (canCacheSessionSearchIndex(store, cached, index)) {
store.cache.set(session, index);
// Keep the eager gate's running total in sync (also covers the
// display-title-change rebuild, which replaces an existing entry).
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
}
return index;
}
/**
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
* on every cache miss: evicting during the scan would make each miss drop the
* index of a later session in the same scan, so any history longer than the
* entry cap would cycle-evict the whole cache and rebuild every session's
* haystack on every keystroke. Deferring eviction keeps every retained entry
* usable within a scan while still bounding the cache — first by dropping
* indexes for sessions no longer in the scanned list, then by evicting the
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
* until both bounds hold, preserving the front of the ranked list.
*
* In practice the eviction loop is cheap: both bounds are enforced eagerly
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
* overshoots the entry cap or char budget mid-scan — the prune only drops
* indexes for sessions that left the scanned list and re-syncs the running
* char total.
*/
function pruneSessionSearchIndexCache(
store: SessionHistorySearchStore,
sessions: readonly SessionHistorySearchTarget[],
): void {
const live = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!live.has(key)) store.cache.delete(key);
}
let retained = 0;
for (const cachedIndex of store.cache.values()) {
retained += indexRetainedChars(cachedIndex);
}
// Re-sync the eager gate's running total: the sum above is authoritative
// here (once per scan), so any drift accumulated mid-scan is corrected.
store.cacheChars = retained;
// Evict from the END of the Map until both bounds hold. Every scan visits
// the full ranked session list newest-first and `getSessionSearchIndex`
// re-appends each visited entry, so after a scan the Map's insertion order
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
// Map order) would therefore drop the newest, highest-ranked sessions and
// retain the stalest tail — every subsequent keystroke would rebuild every
// session above the cache cap. Evicting the back instead preserves the
// front of the ranked list. The single remaining entry is always kept, even
// if it alone exceeds the char budget (a single entry is bounded by ~192K
// chars by the collector caps).
// Snapshot the keys once: prune only deletes (never inserts), so popping
// from this array visits keys exactly in Map order, back to front.
const keysInInsertionOrder = [...store.cache.keys()];
while (
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
) {
// A single entry can never push the cache past the char budget (it is
// bounded by ~192K chars by the collector caps), so keep the last entry.
if (store.cache.size <= 1) break;
const evictKey = keysInInsertionOrder.pop();
if (evictKey === undefined) break;
const evicted = store.cache.get(evictKey);
if (evicted) retained -= indexRetainedChars(evicted);
store.cache.delete(evictKey);
}
store.cacheChars = retained;
}
/**
* Release every cached index and touched-session reference for one store
* (invoked for the drawer's unmount lifecycle via the per-drawer search
* instance). Cache keys are the session objects themselves, so clearing fully
* drops all strong session references — the retained haystack characters
* become garbage together with their sessions.
*/
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
store.cache.clear();
store.scanTouched.clear();
store.cacheChars = 0;
}
export type SessionHistorySearchOptions = {
/**
* Localized fallback label rendered by the drawer for sessions with an
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
* displayed title so searching for the visible label finds the session.
*/
untitledLabel?: string;
};
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
store: SessionHistorySearchStore,
sessions: readonly T[],
query: string,
options: SessionHistorySearchOptions = {},
): T[] {
const trimmed = query.trim();
if (!trimmed) {
// The blank-query path skips the scan entirely; release any stale
// scan-touched state so it cannot pin visited sessions (and their
// message/attachment memory) while the drawer is open without a query.
// Normally this set is already empty — the nonblank scan clears it in its
// `finally` — but clearing here too keeps every entry path leak-free.
store.scanTouched.clear();
// Cache keys are the session objects themselves, so an entry for a
// deleted/replaced session strongly retains that session (and its full
// message/attachment payloads). Prune against the current list here too:
// the blank-query path may run while the drawer stays open after
// deletions, and a later nonblank search that would prune otherwise may
// never happen (e.g. the user just closes the drawer).
pruneSessionSearchIndexCache(store, sessions);
return [...sessions];
}
const untitledLabel = options.untitledLabel ?? '';
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
// query would otherwise be re-normalized (NFKC), tokenized and compacted
// inside `matchesSessionSearchIndex` for every session, freezing the
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
// — so the meaning of over-limit queries stays local to session history and
// the normalized forms are computed a single time, letting the per-session
// work below only do `includes` scans over the cached haystacks.
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
// A new scan starts with no visited sessions: the touched set records only
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
// previous scan's keys for keys the current scan still needs.
store.scanTouched.clear();
try {
// Drop entries still held by sessions that left the scan list (e.g. the
// history shrank or session objects changed since the previous scan). Those
// entries are never visited by this scan — the end-of-scan prune would
// remove them too — but dropping them up front keeps their characters
// available to this scan's inserts (via the eager char gate). Cheap: one
// Set plus one pass over the cached keys.
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!currentLive.has(key)) {
const removed = store.cache.get(key);
if (removed) store.cacheChars -= indexRetainedChars(removed);
store.cache.delete(key);
}
}
// Scan all sessions first (a miss admits its index into the cache —
// evicting this scan's untouched stale tail whenever either bound lacks
// headroom, and skipping the insert only when no untouched entry remains),
// then prune entries that fell out of the scan list at the end of the
// scan.
const result = sessions.filter((session) => {
const index = getSessionSearchIndex(store, session, untitledLabel);
return matchesSessionSearchIndex(preparedQuery, index);
});
// Prune against the full scanned list (not the filtered result): sessions
// that merely did not match this query must keep their cached indexes.
pruneSessionSearchIndexCache(store, sessions);
return result;
} finally {
// The touched set only guards against cycle-eviction within the current
// scan; once the scan ends (even if it throws), release its strong
// references to session objects so deleted/replaced sessions and their
// large message/attachment payloads aren't retained until the next
// nonblank search.
store.scanTouched.clear();
}
}
/**
* One stateful search instance per consumer (one mounted session history
* drawer). The cache lives in the returned instance's store instead of module
* scope, so concurrently mounted drawers (one per terminal tab) never share
* state: an unmount of one drawer clears only that drawer's indexes, and each
* drawer's prune is scoped to its own session list.
*/
export type SessionHistorySearch = {
filterSessionHistory: <T extends SessionHistorySearchTarget>(
sessions: readonly T[],
query: string,
options?: SessionHistorySearchOptions,
) => T[];
clearSessionHistorySearchCache: () => void;
};
export function createSessionHistorySearch(): SessionHistorySearch {
const store: SessionHistorySearchStore = {
cache: new Map(),
scanTouched: new Set(),
cacheChars: 0,
};
return {
filterSessionHistory(sessions, query, options = {}) {
return filterSessionHistoryStore(store, sessions, query, options);
},
clearSessionHistorySearchCache() {
clearSessionHistorySearchStore(store);
},
};
}
@@ -209,13 +209,3 @@ test("restore terminal cwd setting participates in cross-window settings sync",
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
});
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
assert.ok(expression, "renderer settings options must be explicit");
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
assert.equal(resolveOptions(false, false), undefined);
});
@@ -9,14 +9,9 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -145,72 +140,3 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
"--ring": "270 70% 45%",
});
});
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.autoCloseOnExit, false);
// Other saved settings pass through untouched.
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
false,
);
// The user re-enables after upgrading: the migration must not undo it again.
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
true,
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
const store = installMemoryLocalStorage();
try {
// Fresh install: no saved settings at all.
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
// Explicit-off (or any settings blob without the key) is never rewritten.
delete (globalThis as { localStorage?: unknown }).localStorage;
const store2 = installMemoryLocalStorage();
store2.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 500 }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
scrollback: 500,
});
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -29
View File
@@ -1,9 +1,5 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -67,30 +63,6 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
}
return raw;
}
/**
* One-time migration for the autoCloseOnExit default flip (tabs now survive
* session exits by default; the old default-on silently deleted tabs whenever
* a bastion or idle-harvesting server reported a clean exit). Builds before
* the flip shipped autoCloseOnExit: true as the default and materialized it
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
* carry a value they never chose. Leaving it in place would make the new
* default unreachable for them. Reset that materialized `true` exactly once
* and persist the marker; afterwards every stored value is treated as the
* user's own choice and is respected. An inbound cloud or backup payload that
* still carries the pre-migration materialized true (no
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
* before this release must re-enable it once.
*/
export function migrateMaterializedAutoCloseOnExitDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored?.autoCloseOnExit === true) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import { UploadController } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,64 +37,3 @@ test("external upload controls survive their originating panel unmount", async (
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});
for (const selectedFile of ["a.bin", "c.bin"]) {
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const controller = new UploadController();
const cancelled: string[] = [];
const stagedCancelled: string[] = [];
const ids = new Map<string, string>();
const pending = new Map<string, (cancelled?: boolean) => void>();
const started: string[] = [];
const upload = uploadEntriesDirect(
["a.bin", "b.bin", "c.bin"].map((name) => ({
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
})),
{
targetPath: "/remote", sftpId: "fixture", isLocal: false,
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => {
ids.set(task.fileName, task.id);
registerExternalUploadController(task.id, controller, "file");
},
},
bridge: {
mkdirSftp: async () => {},
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
started.push(sourcePath);
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
}),
},
},
controller,
);
t.after(async () => {
await controller.cancel();
for (const finish of pending.values()) finish(true);
await upload;
});
await new Promise(setImmediate);
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
const selectedId = ids.get(selectedFile)!;
await cancelExternalUploadRuntime(selectedId);
assert.deepEqual(cancelled, [selectedId]);
assert.deepEqual(stagedCancelled, [selectedId]);
assert.equal(controller.isCancelled(), false);
await new Promise(setImmediate);
assert.deepEqual(started, selectedFile === "a.bin"
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
: ["/local/a.bin", "/local/b.bin"]);
for (const [id, finish] of pending) if (id !== selectedId) finish();
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
fileName === selectedFile
? { fileName, success: false, cancelled: true }
: { fileName, success: true }
)));
});
}
@@ -5,13 +5,11 @@ import type { UploadController } from "../../../lib/uploadService";
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const scopesByTaskId = new Map<string, "file" | "batch">();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
scope: "file" | "batch" = "batch",
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
@@ -21,7 +19,6 @@ export function registerExternalUploadController(
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
scopesByTaskId.set(taskId, scope);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
@@ -33,7 +30,6 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -44,10 +40,6 @@ export function getExternalUploadController(taskId: string): UploadController |
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
if (taskId && scopesByTaskId.get(taskId) === "file") {
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
return true;
}
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
@@ -58,6 +50,5 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
scopesByTaskId.clear();
taskIdsByController.clear();
}
@@ -520,24 +520,3 @@ test("folder resume releases a completed child compacted while pause was drainin
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch");
const { bumpTransferControlEpoch } = await import("./transferControlEpoch");
const { releaseTransferPauseTree } = await import("./transferPauseLatch");
t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); });
let finishPause!: (value: { success: boolean }) => void;
let resumes = 0;
const { host } = createHost([makeTask("cancel-during-pause")], () => ({
pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }),
resumeTransfer: async () => { resumes += 1; return { success: true }; },
}));
const pause = softPauseTransfer(host, "cancel-during-pause");
markTransferCancelledTree("cancel-during-pause");
bumpTransferControlEpoch("cancel-during-pause");
releaseTransferPauseTree("cancel-during-pause");
finishPause({ success: true });
await pause;
assert.equal(resumes, 0);
});
@@ -24,7 +24,6 @@ import {
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
import { isTransferOrRootCancelled } from "./transferCancelLatch";
import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
@@ -73,7 +72,7 @@ export function reconcileSupersededControls(
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) return;
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
const task = host.getTasks().find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
@@ -235,7 +234,7 @@ export async function softPauseTransfer(
const undoObsoletePause = async (id: string) => {
const live = host.getTasks().find((candidate) => candidate.id === taskId);
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
if (isTransferOrRootPauseLatched(taskId, id)) return;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
@@ -1,46 +0,0 @@
import type { TransferTask } from "../../../domain/models";
import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint";
/** Apply only after backend cancellation AND the old walk have settled. */
export function settleCancelledTransferTree(
tasks: readonly TransferTask[],
rootId: string,
cancelledIds: ReadonlySet<string>,
failedIds: ReadonlySet<string>,
retainedTasks: readonly TransferTask[] = [],
): TransferTask[] {
const existingTasks = new Map(tasks.map((task) => [task.id, task]));
// Use observed terminal outcomes rather than stale pre-cancel snapshots.
// Missing completed children already covered by the parent stay compacted;
// cancelled/failed exceptions still need their rows for the recovery walk.
const settledTasks = failedIds.size > 0
? [...tasks, ...retainedTasks.filter((task) => {
if (existingTasks.has(task.id)) return false;
const checkpoint = task.parentTaskId
? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint
: undefined;
return !(task.status === "completed"
&& isValidDirectoryResumeCheckpoint(checkpoint)
&& Number.isSafeInteger(task.directoryEntryIndex)
&& (task.directoryEntryIndex ?? -1) >= 0
&& task.directoryEntryIndex! < checkpoint.coveredEntries);
})]
: tasks;
return settledTasks.map((task) => {
// Natural completion/failure wins a race with Cancel. Rows cancelled by
// this attempt can be re-admitted only when the whole tree needs recovery.
if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task;
const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0);
const recoverable = failedIds.size > 0;
return {
...task,
status: failed ? "attention" : recoverable ? "interrupted" : "cancelled",
error: failed ? "Could not cancel transfer. Please try again." : undefined,
reconnectRequired: recoverable,
endTime: recoverable ? undefined : Date.now(),
speed: 0,
phase: undefined,
conflict: undefined,
};
});
}
@@ -1,375 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch";
import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime";
import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry";
const task = (id: string, extra: Partial<TransferTask> = {}): TransferTask => ({
id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`,
sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload",
status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0,
startTime: 1, isDirectory: false, resumable: true, ...extra,
});
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => {
const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-"));
const originalGet = netcattyBridge.get;
t.after(async () => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
await rm(root, { recursive: true, force: true });
});
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
const ids = ["active", "queued-1", "queued-2"];
const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`));
const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) }));
store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
let softResumeCalls = 0;
let pauseCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "active" }),
pauseTransfer: async () => { pauseCalls += 1; return { success: true }; },
clearPendingTransferCancel: async () => undefined,
resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
let starts = 0;
const walking = runtime.runWalk("folder", async () => {
await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run(
"closed-panel", child.id, ["cancel-fixture"], () => 1, async () => {
starts += 1;
await held;
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
},
).catch(() => store.patchTask(child.id, { status: "cancelled" }))));
store.patchTask("folder", { status: "cancelled" });
});
await tick();
assert.equal(starts, 1);
let cancelSettled = false;
const cancelling = store.cancel("folder").then(() => { cancelSettled = true; });
const resuming = store.resume("folder");
await tick();
assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry");
assert.equal(store.getTask("folder")?.status, "attention");
assert.equal(isTransferOrRootCancelled("folder"), true);
assert.equal(softResumeCalls, 0, "must not unlatch the old walk");
await store.pause("folder");
assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk");
let freshWalks = 0;
store.setDedicatedResumeHandler(async () => {
freshWalks += 1;
assert.equal(runtime.isWalkInFlight("folder"), false);
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (store.getTask(child.id)?.status === "completed") continue;
assert.equal(store.admitTaskRun(child), "ready");
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
}
return { success: true };
});
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(freshWalks, 1);
assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files");
assert.equal(store.getTask("folder")?.status, "completed");
for (let index = 0; index < children.length; index += 1) {
assert.deepEqual(await readFile(children[index].targetPath), expected[index]);
}
});
test("failed ownerless compression cancellation retries the same backend", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async (id: string) => {
calls.push(`compressed:${id}`);
return { success: calls.length > 1 };
},
cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]);
await store.cancel("archive");
assert.equal(store.getTask("archive")?.status, "attention");
await store.cancel("archive");
assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]);
assert.equal(store.getTask("archive")?.status, "cancelled");
});
test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]);
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
store.patchTask(id, { status: "completed" });
return { success: true };
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("selected");
assert.deepEqual(calls, ["selected"]);
assert.equal(store.getTask("selected")?.status, "completed");
assert.equal(store.getTask("peer")?.status, "paused");
});
test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => {
const { markTransferCancelledTree } = await import("./transferCancelLatch");
t.after(resetTransferCancelLatchesForTests);
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]);
markTransferCancelledTree("waiting-cancel-batch");
await store.resume("waiting-cancel-batch");
assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused");
assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true);
});
test("failed cancel can be retried while its old walk is still running", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
const walking = runtime.runWalk("retry-live-cancel", async () => { await held; });
let attempts = 0;
netcattyBridge.get = () => ({
cancelTransfer: async () => {
attempts += 1;
if (attempts === 1) return { success: false };
release();
return { success: true };
},
cleanupTransferArtifacts: async () => undefined,
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("retry-live-cancel");
assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true);
assert.equal(store.getTask("retry-live-cancel")?.status, "attention");
const resume = store.resume("retry-live-cancel");
await store.cancel("retry-live-cancel");
await Promise.all([walking, resume]);
assert.equal(attempts, 2, "second cancel must reach the backend before natural completion");
assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome");
});
test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }),
...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]);
let active = 0;
let peak = 0;
let cleared = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }),
clearPendingTransferCancel: async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => setImmediate(resolve));
active -= 1;
cleared += 1;
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("cleanup-root");
assert.equal(cleared, 2201);
assert.ok(peak > 1, "recovery must not serialize one IPC per row");
assert.ok(peak <= 32, "recovery uses the cancellation batch bound");
});
for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) {
test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint");
const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } =
await import("./dedicatedTransferResume");
const originalGet = netcattyBridge.get;
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const parent = task(`compacted-cancel-${completeDuring}`, {
sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host",
isDirectory: true, progressMode: "files", totalBytes: 3,
});
const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => {
const child = task(`${parent.id}-${name}`, {
parentTaskId: parent.id, fileName: name,
sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`,
sourceLastModified: 1, directoryEntryIndex: index,
});
return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1,
}) };
});
// Match the sorted traversal order so dedicated resume validates the actual checkpoint.
children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
children.forEach((child, index) => { child.directoryEntryIndex = index; });
const completed = children.find((child) => child.fileName === "completed")!;
const cancelled = children.find((child) => child.fileName === "cancelled")!;
const failed = children.find((child) => child.fileName === "cancel-failed")!;
const runtime = createTransferRuntime(store);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let walking: Promise<unknown> | undefined;
t.after(async () => {
release();
await walking;
store.setDedicatedResumeHandler(null);
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
resetDedicatedSessionOpenGateForTests();
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const uploads: string[] = [];
const progress: number[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
if (completeDuring === "backend-cancel" && id === completed.id) {
store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 });
}
return { success: id !== failed.id };
},
clearPendingTransferCancel: async () => undefined,
openSftp: async () => "reconnected-sftp", closeSftp: async () => {},
listLocalTree: async () => children.map((child) => ({
localPath: child.sourcePath, relativePath: child.fileName,
type: "file", size: 8, lastModified: 1,
})),
mkdirSftp: async () => {},
statLocal: async () => ({ size: 8, lastModified: 1 }),
startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => {
if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4);
uploads.push(options.sourcePath);
return {};
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [parent, ...children]);
walking = runtime.runWalk(parent.id, async () => {
await held;
if (completeDuring === "walk-settlement") {
store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 });
}
store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 });
store.patchTask(parent.id, { status: "cancelled" });
});
await store.cancel(parent.id);
assert.equal(runtime.isWalkInFlight(parent.id), true);
store.setDedicatedResumeHandler(async (root) => {
assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1);
assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted");
assert.equal(store.getTask(cancelled.id)?.status, "interrupted");
assert.equal(store.getTask(failed.id)?.status, "attention");
return resumeTransferWithDedicatedSession(root, {
hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22,
username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }],
keys: [], identities: [],
}, (value) => { progress.push(value.transferred); }, {
children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id),
onChildUpdate: (child) => { store.upsertTasks([child]); },
onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }),
});
});
const resuming = store.resume(parent.id);
release();
await Promise.all([walking, resuming]);
assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(),
"only cancelled and failed-to-cancel children should upload again");
assert.equal(store.getTask(parent.id)?.status, "completed");
assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once");
assert.equal(progress.at(-1), 3);
});
}
for (const rejects of [false, true]) {
test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async () => {
if (rejects) throw new Error("Cancel IPC failed");
return { success: false };
},
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
store.publishOwner("closed-panel", [task("live-compressed", {
isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
})]);
await store.cancel("live-compressed");
assert.equal(store.getTask("live-compressed")?.status, "attention");
await store.resume("live-compressed");
assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer");
assert.equal(store.getTask("live-compressed")?.status, "transferring");
});
}
for (const hasConflict of [false, true]) {
test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => {
const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter");
const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions");
const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration");
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const saved = task("restored-compressed", {
status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
error: "Could not cancel the compressed upload.",
...(hasConflict ? { conflict: {
transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder",
isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1,
} } : {}),
});
const raw = serializeSftpTransferCenter([saved]);
const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} });
const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] }));
assert.equal(restored.getTask(saved.id)?.reconnectRequired, true);
assert.equal(cooperative.tasks[0]?.reconnectRequired, true);
assert.equal(restored.getTask(saved.id)?.status, "attention");
assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict);
assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row");
const calls: string[] = [];
netcattyBridge.get = () => ({
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks);
assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]);
for (const id of resumeIds) await restored.resume(id);
assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]);
});
}
@@ -972,10 +972,13 @@ export function useSftpDirectoryTransferOps({
if (isTransferCancelledError(err)) {
// Keep cancelled status; do not rethrow — other workers must finish
// and the parent should not become a clean completed tree.
setTransfers((prev) => prev.map((task) =>
task.id === fileId
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: task));
setTransfers((prev) =>
prev.map((t) =>
t.id === fileId
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: t,
),
);
errors.push(err instanceof Error ? err : new Error(message));
return;
}
@@ -51,7 +51,6 @@ export interface TransferRuntime {
* walk is dead / reconnectRequired. Single external operation.
*/
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
cancel(taskId: string): Promise<void>;
/**
+11 -14
View File
@@ -71,24 +71,21 @@ export function useSftpTransferTaskOps({
const bridge = netcattyBridge.get();
const cancelTransferAtBackend = bridge?.cancelTransfer;
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return [];
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
const failed: string[] = [];
const ids = [...idsToCancel];
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (id) => {
await Promise.all(
Array.from(idsToCancel).map(async (id) => {
const candidate = currentTransfers.find((task) => task.id === id);
const compressed = candidate?.controlKind === "compressed-upload";
try {
const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id));
if (result && result.success === false) failed.push(id);
} catch {
failed.push(id);
const operation = compressed
? cancelCompressedUpload?.(id)
: cancelTransferAtBackend?.(id);
const results = operation ? await Promise.allSettled([operation]) : [];
if (results.some((result) => result.status === "rejected")) {
logger.warn("Failed to cancel one or more transfer backends");
}
}));
}
if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends");
return failed;
}),
);
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
const markBatchStopped = useCallback(
+1 -15
View File
@@ -6,7 +6,6 @@
*/
const inFlightRootIds = new Set<string>();
const settlementWaiters = new Map<string, Set<() => void>>();
export function registerTransferWalk(rootTaskId: string): void {
inFlightRootIds.add(rootTaskId);
@@ -14,28 +13,15 @@ export function registerTransferWalk(rootTaskId: string): void {
export function unregisterTransferWalk(rootTaskId: string): void {
inFlightRootIds.delete(rootTaskId);
const waiters = settlementWaiters.get(rootTaskId);
settlementWaiters.delete(rootTaskId);
for (const resolve of waiters ?? []) resolve();
}
export function isTransferWalkInFlight(rootTaskId: string): boolean {
return inFlightRootIds.has(rootTaskId);
}
/** Wait for the current walk to finish; cancellation must not resume a dying walk. */
export function waitForTransferWalkSettled(rootTaskId: string): Promise<void> {
if (!inFlightRootIds.has(rootTaskId)) return Promise.resolve();
return new Promise((resolve) => {
const waiters = settlementWaiters.get(rootTaskId) ?? new Set();
waiters.add(resolve);
settlementWaiters.set(rootTaskId, waiters);
});
}
/** Test helper. */
export function resetTransferWalkRegistryForTests(): void {
for (const id of inFlightRootIds) unregisterTransferWalk(id);
inFlightRootIds.clear();
}
export function listTransferWalksForTests(): string[] {
@@ -79,7 +79,6 @@ import { localStorageAdapter } from "../../../infrastructure/persistence/localSt
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { editorTabStore } from "../editorTabStore";
import { notifyEditorWindowSessionRemap } from "../editorWindowClient";
import {
resolveUploadStreamTargetSftpId,
} from "../../../domain/sftpDedicatedStreamPolicy";
@@ -201,8 +200,8 @@ export const useSftpExternalOperations = (
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -222,7 +221,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
registerUploadController(task.id, controller);
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -444,7 +443,6 @@ export const useSftpExternalOperations = (
if (liveConnectionId !== connectionId) {
editorTabStore.remapSessionId(connectionId, liveConnectionId);
notifyEditorWindowSessionRemap(connectionId, liveConnectionId);
}
const bridge = netcattyBridge.get();
@@ -561,105 +561,3 @@ test("files past the preflight bound stay un-statted after conflict resolution",
restore();
}
});
test("owned cancelled directory settles recoverable children without dropping completed bytes", async () => {
const { writeFile, readFile, mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { transferRuntime } = await import("./transferRuntime");
const root = await mkdtemp(join(tmpdir(), "netcatty-owner-cancel-"));
const completedPath = join(root, "completed.txt");
await writeFile(completedPath, "completed bytes must remain");
const ownerId = "cancel-owner-large";
const rootId = "cancel-root-large";
const base = {
id: rootId, fileName: "folder", sourcePath: "/opt/folder", targetPath: root,
sourceConnectionId: "remote-conn", targetConnectionId: "local-conn",
sourceHostId: "host-1", direction: "download" as const, status: "transferring" as const,
totalBytes: 2201, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true,
};
const children = Array.from({ length: 2200 }, (_, index) => ({
...base, id: `cancel-child-${index}`, parentTaskId: rootId, isDirectory: false,
sourcePath: `/opt/folder/${index}`, targetPath: join(root, String(index)),
totalBytes: 10, transferredBytes: 4, status: "queued" as const,
}));
sftpTransferCenterStore.publishOwner(ownerId, [base, ...children, {
...base, id: "cancel-completed", parentTaskId: rootId, isDirectory: false,
sourcePath: "/opt/folder/completed.txt", targetPath: completedPath, status: "completed",
}]);
let active = 0;
let peak = 0;
let clearing = 0;
let clearPeak = 0;
let clearCount = 0;
const cleaned: string[] = [];
const restore = installGlobals({
cancelTransfer: async (id: string) => {
assert.notEqual(id, "cancel-completed");
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== "cancel-child-0" };
},
clearPendingTransferCancel: async () => {
clearing += 1;
clearPeak = Math.max(clearPeak, clearing);
await new Promise((resolve) => setImmediate(resolve));
clearing -= 1;
clearCount += 1;
},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side),
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
});
return null;
}
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const walking = transferRuntime.runWalk(rootId, async () => {
await held;
// Old scheduler callbacks settle as cancelled before the outcome is applied.
sftpTransferCenterStore.publishOwner(ownerId, sftpTransferCenterStore.getOwnerTasks(ownerId).map((task) =>
task.status === "completed" ? task : {
...task, status: "cancelled",
checkpointBytes: task.id === "cancel-child-1" ? 6 : task.checkpointBytes,
}));
});
await act(async () => {
const cancellation = ops!.cancelTransfer(rootId);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
release();
await Promise.all([walking, cancellation]);
});
assert.ok(peak <= 32, `bounded cancellation IPC, got ${peak}`);
assert.equal(clearCount, 2201);
assert.ok(clearPeak > 1 && clearPeak <= 32, `bounded concurrent cleanup, got ${clearPeak}`);
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "attention");
assert.equal(sftpTransferCenterStore.getTask("cancel-child-0")?.status, "attention");
const remaining = sftpTransferCenterStore.getTask("cancel-child-1");
assert.equal(remaining?.status, "interrupted", "evicted cancelled children remain recoverable");
assert.equal(remaining?.checkpointBytes, 6, "retain the terminal checkpoint, not the pre-cancel snapshot");
assert.deepEqual(cleaned, [], "no stage cleanup during partial cancellation recovery");
assert.equal(await readFile(completedPath, "utf8"), "completed bytes must remain");
} finally {
release();
await act(async () => { renderer?.unmount(); });
restore();
await rm(root, { recursive: true, force: true });
}
});
+64 -52
View File
@@ -1418,66 +1418,77 @@ export const useSftpTransfers = ({
releasePausedTransfer(transferId);
globalSftpTransferScheduler.cancel(transferId);
// Keep child rows and checkpoints until the old walk has stopped. A
// partial backend failure needs a fresh re-walk, including queued jobs
// the scheduler has already rejected and will never run again.
const children = transfersRef.current.filter((task) =>
task.parentTaskId === transferId
&& !["completed", "cancelled", "failed"].includes(task.status));
const childIds = new Set([
...children.map((task) => task.id),
...(activeChildIdsRef.current.get(transferId) ?? []),
]);
for (const id of childIds) {
cancelledTasksRef.current.add(id);
releasePausedTransfer(id);
globalSftpTransferScheduler.cancel(id);
// Cancel parent + remove child tasks
const childIdsToCancel = new Set<string>();
const childrenToCleanup: TransferTask[] = [];
for (const t of transfersRef.current) {
if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) {
childIdsToCancel.add(t.id);
childrenToCleanup.push(t);
}
}
markTransferCancelledTree(transferId, [...childIds]);
conflictsRef.current = conflictsRef.current.filter((conflict) =>
conflict.transferId !== transferId && !childIds.has(conflict.transferId));
for (const cid of activeChildIdsRef.current.get(transferId) ?? []) {
if (!childIdsToCancel.has(cid)) {
childIdsToCancel.add(cid);
if (taskToCancel) {
childrenToCleanup.push({
...taskToCancel,
id: cid,
parentTaskId: transferId,
});
}
}
}
for (const cid of childIdsToCancel) {
cancelledTasksRef.current.add(cid);
markTransferCancelled(cid);
globalSftpTransferScheduler.cancel(cid);
}
markTransferCancelledTree(transferId, [...childIdsToCancel]);
// Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel.
const nextTransfers = transfersRef.current
.filter((t) => t.parentTaskId !== transferId)
.map((t) =>
t.id === transferId
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
: t,
);
setTransfers(nextTransfers);
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId));
setConflicts(conflictsRef.current);
const ids = new Set([transferId, ...childIds]);
const retainedTasks = (taskToCancel ? [taskToCancel, ...children] : children)
.map((task) => ({ task, ...sftpTransferCenterStore.observeTaskSettlement(task) }));
const failedIds = new Set(await cancelBackendTransfers([...ids]));
await sftpTransferCenterStore.settleCancellation(
transferId, ids, failedIds, retainedTasks,
async () => {
setTransfers(sftpTransferCenterStore.getOwnerTasks(ownerId));
for (const id of ids) clearCancelledTask(id);
clearTransferCancelledTree(transferId, [...childIds]);
if (failedIds.size > 0) {
// Preserve stages while any writer could not be cancelled. The
// fresh recovery walk reuses ids only after this settlement.
const recoveryIds = [...ids];
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
return;
}
if (taskToCancel && sftpTransferCenterStore.getTask(transferId)?.status === "cancelled") {
try { await cleanupTaskArtifacts(taskToCancel); } catch { /* best-effort */ }
}
for (const child of children) {
if (sftpTransferCenterStore.getTask(child.id)?.status === "completed") continue;
try { await cleanupTaskArtifacts(child); } catch { /* best-effort */ }
}
},
);
await cancelBackendTransfers([transferId, ...childIdsToCancel]);
if (taskToCancel) await cleanupTaskArtifacts(taskToCancel);
// Child stages are keyed by per-file transferId — clean each known child.
for (const child of childrenToCleanup) {
try {
await cleanupTaskArtifacts(child);
} catch {
// best-effort
}
}
},
[cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers],
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
);
// All controls go through the process-global runtime, including cancellation
// settlement. A panel must never clear cancellation latches on Resume.
// Soft pause/resume: single TransferRuntime entry (store soft-control +
// dedicated hard reconnect). No panel-local soft-control dual path.
const resumeTransfer = useCallback(async (transferId: string) => {
// Clear sticky child cancel latches so re-walk can retry same child ids.
clearCancelledTask(transferId);
for (const child of transfersRef.current) {
if (child.parentTaskId === transferId) clearCancelledTask(child.id);
}
for (const childId of activeChildIdsRef.current.get(transferId) ?? []) {
clearCancelledTask(childId);
}
clearTransferCancelledTree(
transferId,
[...(activeChildIdsRef.current.get(transferId) ?? [])],
);
await transferRuntime.resume(transferId);
}, []);
}, [clearCancelledTask]);
const prioritizeTransfer = useCallback((transferId: string) => {
globalSftpTransferScheduler.prioritize(transferId);
@@ -2254,6 +2265,7 @@ export const useSftpTransfers = ({
await transferRuntime.pause(taskId);
},
resume: async (taskId: string) => {
// Use resumeTransfer so cancel latches are cleared before soft/hard resume.
await resumeTransfer(taskId);
},
cancel: cancelTransfer,
@@ -2278,7 +2290,7 @@ export const useSftpTransfers = ({
activeTransfersCount,
startTransfer,
downloadToLocal,
cancelTransfer: transferRuntime.cancel,
cancelTransfer,
// Single process-level control surface (TransferRuntime).
pauseTransfer: async (transferId: string) => {
await transferRuntime.pause(transferId);
@@ -1622,46 +1622,6 @@ test("orphan compressed upload resume and cancel keep using the compression job"
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("compressed upload completing during cancel IPC keeps its completed paint", async (t) => {
const calls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let storeRef: ReturnType<typeof createSftpTransferCenterStore> | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
// Inactive-compression responses report success even after the upload
// already finished; the completion repaint must survive the cancel.
cancelCompressedUpload: async (id: string) => {
calls.push(`cancel:${id}`);
storeRef?.ingestBackgroundEvent({ type: "completed", transferId: id });
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
storeRef = store;
store.publishOwner("closed-panel", [{
...makeTask("compressed-race", "transferring"),
fileName: "done (compressed)",
isDirectory: true,
phase: "compressing",
controlKind: "compressed-upload",
} as TransferTask]);
await store.cancel("compressed-race");
assert.deepEqual(calls, ["cancel:compressed-race"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => {
const {
isTransferCancelledFlag,
@@ -1690,10 +1650,6 @@ test("orphan cancel marks process-global cancel so surviving walks stop", async
}]);
await store.cancel("walk-1");
assert.equal(isTransferCancelledFlag("walk-1"), true);
const resuming = store.resume("walk-1");
unregisterTransferWalk("walk-1");
await resuming;
assert.equal(isTransferCancelledFlag("walk-1"), false);
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled");
});
@@ -2750,39 +2706,6 @@ test("an interrupted task without its old controller can still be cancelled", as
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("orphan cancel keeps failed work visible and leaves completed children alone", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const cancelledIds: string[] = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
cancelTransfer: async (id: string) => {
cancelledIds.push(id);
return { success: id !== "unfinished" };
},
} },
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [
{ ...makeTask("directory", "paused"), isDirectory: true },
{ ...makeTask("finished", "completed"), parentTaskId: "directory" },
{ ...makeTask("unfinished", "queued"), parentTaskId: "directory" },
]);
const finishedBeforeCancel = store.getTask("finished");
await store.cancel("directory");
assert.deepEqual(cancelledIds, ["directory", "unfinished"]);
assert.equal(store.getTask("finished")?.status, finishedBeforeCancel?.status);
assert.equal(store.getTask("unfinished")?.status, "attention");
assert.equal(store.getTask("directory")?.status, "attention");
assert.match(store.getTask("directory")?.error ?? "", /Could not cancel/);
});
test("concurrent resume clicks adopt a task only once", async () => {
let adoptCount = 0;
const store = createSftpTransferCenterStore();
@@ -3944,66 +3867,3 @@ for (const outcome of ["rejected", "stream-gone"] as const) {
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
test("partial cancel failure resets recovered folder children so the re-walk can re-admit them", async (t) => {
const { netcattyBridge } = await import("../../infrastructure/services/netcattyBridge");
const { resetTransferCancelLatchesForTests, isTransferOrRootCancelled } =
await import("./sftp/transferCancelLatch");
const { resetTransferWalkRegistryForTests } = await import("./sftp/transferWalkRegistry");
const originalGet = netcattyBridge.get;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
t.after(() => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
});
// Only child2 fails to cancel; the folder must stay recoverable via Resume.
let failChild2 = true;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "dir-child-2" || !failChild2 }),
cleanupTransferArtifacts: async () => ({}),
} as unknown as ReturnType<typeof netcattyBridge.get>);
const store = createSftpTransferCenterStore();
const child = (id: string): TransferTask => ({
...makeTask(id),
parentTaskId: "dir-root",
isDirectory: false,
transferredBytes: 4,
directoryEntryIndex: 0,
directoryEntryIdentity: "a".repeat(64),
});
store.publishOwner("gone-panel", [
{ ...makeTask("dir-root"), isDirectory: true, transferredBytes: 10, totalBytes: 20, directoryEntryIndex: 0 },
child("dir-child-1"),
child("dir-child-2"),
]);
await store.cancel("dir-root");
const rows = store.getSnapshot().tasks;
const parent = rows.find((row) => row.id === "dir-root");
const child1 = rows.find((row) => row.id === "dir-child-1");
const child2 = rows.find((row) => row.id === "dir-child-2");
assert.equal(parent?.status, "attention");
assert.equal(parent?.error, "Could not cancel transfer. Please try again.");
// A failed backend cancellation keeps the folder recoverable: successfully
// cancelled siblings must not settle as terminal "cancelled", or admitTaskRun
// would reject the recovery re-walk and leave the destination incomplete.
assert.equal(child1?.status, "interrupted");
assert.equal(child1?.error, undefined);
assert.equal(child2?.status, "attention");
assert.equal(isTransferOrRootCancelled("dir-root"), false, "recovery latch must be cleared");
// The directory re-walk (Resume) can re-admit the reset sibling.
const incoming = { ...child("dir-child-1"), directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64) };
assert.equal(store.admitTaskRun(incoming), "ready");
assert.equal(store.getTask("dir-child-1")?.status, "transferring");
// Without any cancellation failure the tree still settles fully cancelled.
failChild2 = false;
await store.cancel("dir-root");
const afterFullCancel = store.getSnapshot().tasks;
for (const id of ["dir-root", "dir-child-1", "dir-child-2"]) {
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
}
});
+34 -133
View File
@@ -26,10 +26,8 @@ import {
isTransferControlEpochCurrent,
settleTransferControlEpochTree,
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry";
import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import {
clearTransferCancelledTree,
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
@@ -95,12 +93,6 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{
resetCheckpoint?: boolean;
}>;
interface RetainedCancellationTask {
task: TransferTask;
read(): TransferTask | undefined;
dispose(): void;
}
export interface SftpTransferCenterStore {
subscribe(listener: Listener): () => void;
/**
@@ -129,10 +121,7 @@ export interface SftpTransferCenterStore {
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; live walks settle separately. */
cancel(taskId: string): Promise<void>;
/** Publish one cancellation outcome after the old execution has settled. */
settleCancellation(taskId: string, taskIds: ReadonlySet<string>, failedIds: ReadonlySet<string>, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise<void>): Promise<void>;
retry(taskId: string): Promise<void>;
prioritize(taskId: string): Promise<void>;
dismiss(taskId: string): void;
@@ -475,8 +464,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
const cancelInvocations = new Map<string, Promise<void>>();
const cancelSettlements = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
@@ -831,7 +818,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Compressed soft-control is process-global: do not gate on a live panel owner.
const liveCompressedJob = task?.controlKind === "compressed-upload"
&& task.reconnectRequired !== true
&& task.status !== "interrupted";
&& task.status !== "interrupted"
&& task.status !== "attention";
if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) {
const bridge = netcattyBridge.get();
if (action === "pause") {
@@ -891,17 +879,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (!result.success && result.reason) notify.warning(result.reason, "SFTP");
return;
}
const result = await Promise.resolve(bridge?.cancelCompressedUpload?.(taskId)
?? { success: false }).catch(() => ({ success: false }));
// The upload may finish while this cancel is still awaiting IPC: its
// completion event has already repainted the row terminal. The new
// inactive-compression response still reports success, so recheck the
// latest row before applying the cancellation result — otherwise Cancel
// all misreports a successfully completed upload as cancelled.
const latestAfterCancelIpc = tasks.find((candidate) => candidate.id === taskId);
if (latestAfterCancelIpc && ["completed", "failed", "cancelled"].includes(latestAfterCancelIpc.status)) {
return;
}
const result = await (bridge?.cancelCompressedUpload?.(taskId)
?? { success: false });
if (!result.success) {
const reason = "Could not cancel the compressed upload.";
tasks = tasks.map((candidate) => candidate.id === taskId ? {
@@ -910,13 +889,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
error: reason,
speed: 0,
} : candidate);
if (isTransferOrRootCancelled(taskId)) {
// The row is kept in attention for recovery instead of settling as
// cancelled. Drop the pre-installed cancellation latch now, or a
// later Resume is rejected outright by admitTaskRun's cancelled-root
// check (compressed uploads have no runWalk settlement to clear it).
clearTransferCancelledTree(taskId);
}
emit();
notify.warning(reason, "SFTP");
return;
@@ -1329,9 +1301,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
emit();
}
}
if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) {
if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status))
.filter((candidate) => candidate.parentTaskId === taskId)
.map((candidate) => candidate.id);
// Stop surviving processTransfer walks immediately (panel-local
// cancelledTasksRef is gone after unmount).
@@ -1343,43 +1315,33 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
} catch {
// best-effort
}
const failedIds = new Set<string>();
const cancelIds = [taskId, ...childIds];
const cancelledIdSet = new Set(cancelIds);
const retainedTasks = tasks.filter((candidate) => cancelledIdSet.has(candidate.id))
.map((task) => ({ task, ...store.observeTaskSettlement(task) }));
for (let offset = 0; offset < cancelIds.length; offset += 32) {
await Promise.all(cancelIds.slice(offset, offset + 32).map(async (id) => {
try {
const result: unknown = await netcattyBridge.get()?.cancelTransfer?.(id);
if (result && typeof result === "object" && "success" in result && result.success === false) {
failedIds.add(id);
}
} catch {
failedIds.add(id);
}
}));
}
await store.settleCancellation(taskId, cancelledIdSet, failedIds, retainedTasks, async () => {
clearTransferCancelledTree(taskId, childIds);
if (failedIds.size > 0) {
const recoveryIds = cancelIds;
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
} else if (tasks.find((candidate) => candidate.id === taskId)?.status === "cancelled") {
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch { /* best-effort */ }
try {
await netcattyBridge.get()?.cancelTransfer?.(taskId);
for (const childId of childIds) {
try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ }
}
});
} catch {
// Best-effort backend cancel when the owning panel is gone / no window.
}
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch {
// best-effort temp/.part cleanup
}
const cancelIds = new Set([taskId, ...childIds]);
tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? {
...candidate,
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
conflict: undefined,
} : candidate);
emit();
return;
}
@@ -1832,28 +1794,10 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
if (cancelInvocations.has(taskId) || isTransferOrRootCancelled(taskId)) return Promise.resolve();
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
// A cancelled scheduler job cannot be soft-resumed. Wait for the cancel
// outcome and fresh re-walk rather than unlatching the old walk.
const cancelling = cancelInvocations.get(taskId);
if (cancelling) {
await cancelling;
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
}
// Failed cancellation reports immediately, but its old walk must still
// settle before Resume can start a fresh scan. A retried Cancel may have
// replaced the awaited outcome, so drain the latest settlement as well.
while (cancelSettlements.has(taskId)) await cancelSettlements.get(taskId);
const afterCancellation = tasks.find((candidate) => candidate.id === taskId);
if (!afterCancellation || ["completed", "cancelled"].includes(afterCancellation.status)) return;
// Cancel all latches every selected tree before its bounded IPC batches.
// A row-level Resume must not revive a tree whose batch has not run yet.
if (isTransferOrRootCancelled(taskId)) return;
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
@@ -1969,52 +1913,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
},
async settleCancellation(taskId, taskIds, failedIds, retainedTasks, onSettled) {
const walkStillRunning = isTransferWalkInFlight(taskId);
const settlement = (async () => {
await waitForTransferWalkSettled(taskId);
// A later cancel attempt owns the outcome, even if this older attempt
// failed. Never repaint a successful retry as a failed cancellation.
if (cancelSettlements.get(taskId) !== settlement) return;
tasks = settleCancelledTransferTree(
tasks, taskId, taskIds, failedIds,
retainedTasks.map((retained) => retained.read() ?? retained.task),
);
await onSettled();
emit();
})().finally(() => {
for (const retained of retainedTasks) retained.dispose();
if (cancelSettlements.get(taskId) === settlement) cancelSettlements.delete(taskId);
});
cancelSettlements.set(taskId, settlement);
if (walkStillRunning) {
if (failedIds.size > 0) {
tasks = tasks.map((task) => task.id === taskId
&& !["completed", "failed"].includes(task.status)
? { ...task, status: "attention", error: "Could not cancel transfer. Please try again.", speed: 0 }
: task);
emit();
}
// Backend requests are done: let Cancel all dispatch its next batch.
// Resume, final status, and cleanup still wait on this settlement.
void settlement.catch(() => {});
return;
}
await settlement;
},
cancel(taskId) {
clearResumeRequest(taskId);
const existing = cancelInvocations.get(taskId);
if (existing) return existing;
bumpTransferControlEpoch(taskId);
for (const task of tasks) {
if (task.parentTaskId === taskId) bumpTransferControlEpoch(task.id);
}
const running = invoke(taskId, "cancel").finally(() => {
if (cancelInvocations.get(taskId) === running) cancelInvocations.delete(taskId);
});
cancelInvocations.set(taskId, running);
return running;
return invoke(taskId, "cancel");
},
async retry(taskId) {
const ownerId = findOwner(taskId);
@@ -58,52 +58,6 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("transport drops still auto reconnect", () => {
for (const evt of [
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
}
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
+1 -10
View File
@@ -40,16 +40,7 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
@@ -224,31 +224,3 @@ test("split and copy session clones preserve isolated plugin connection snapshot
assert.equal(split.reuseConnectionFromSessionId, undefined);
assert.equal(copied.reuseConnectionFromSessionId, undefined);
});
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
const source = session({ protocol: "local", localShell, shellType });
const copied = clone(source, { id: "clone", localShellType: "posix" });
assert.equal(copied.localShell, localShell);
assert.equal(copied.shellType, shellType);
assert.equal(clone({ ...source, shellType: undefined },
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
}
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
{ id: "clone-default", localShellType: "posix" });
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
});
}
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
for (const clone of [
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
]) {
assert.equal(clone.localShell, "");
assert.equal(clone.shellType, "powershell");
assert.equal(clone.localStartDir, "~");
}
});
+1 -7
View File
@@ -1,5 +1,4 @@
import type { TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
type SftpReuseCandidate = Pick<
TerminalSession,
@@ -45,12 +44,7 @@ function getClonedShellType(
session: TerminalSession,
localShellType?: TerminalSession["shellType"],
): TerminalSession["shellType"] {
if (session.protocol !== "local") return session.shellType;
// Clones retain an explicit per-session executable, so its shell type must
// survive a different global default (e.g. saved CMD with WSL in Settings).
return session.localShell !== undefined
? (session.shellType ?? classifyLocalShellType(session.localShell))
: localShellType;
return session.protocol === "local" ? localShellType : session.shellType;
}
function createTerminalSessionClone(
-33
View File
@@ -86,11 +86,6 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -702,32 +697,6 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1306,7 +1275,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1367,7 +1335,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1,194 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
-74
View File
@@ -1,74 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { editorTabStore } from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import type { EditorTabId } from "./editorTabStore";
export function useEditorWindowRuntime() {
const [activeTabId, setActiveTabId] = useState<EditorTabId | null>(
() => editorTabStore.getTabs()[editorTabStore.getTabs().length - 1]?.id ?? null,
);
const closeWindow = useCallback(async () => {
await netcattyBridge.get()?.windowClose?.();
}, []);
const setWindowTitle = useCallback(async (title: string) => {
await netcattyBridge.get()?.setWindowTitle?.(title);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowOpenTab) return () => {};
return bridge.onEditorWindowOpenTab((snapshot: EditorWindowTabSnapshot) => {
const existing = editorTabStore.getTab(snapshot.editorId)
?? editorTabStore.getTabs().find((tab) => (
tab.sessionId === snapshot.sessionId && tab.remotePath === snapshot.remotePath
));
if (existing) {
if (existing.sessionId !== snapshot.sessionId) {
editorTabStore.remapSessionId(existing.sessionId, snapshot.sessionId);
}
if (existing.sftpTabId !== snapshot.sftpTabId || existing.hostLabel !== snapshot.hostLabel) {
editorTabStore.upsertFromSnapshot({
...snapshot,
content: existing.content,
baselineContent: existing.baselineContent,
viewState: existing.viewState,
wordWrap: existing.wordWrap,
languageId: existing.languageId,
}, "tab");
}
setActiveTabId(existing.id);
return;
}
const id = editorTabStore.upsertFromSnapshot(snapshot, "tab");
setActiveTabId(id);
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowActivateTab) return () => {};
return bridge.onEditorWindowActivateTab((payload) => {
if (payload?.editorId && editorTabStore.getTab(payload.editorId)) {
setActiveTabId(payload.editorId);
}
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowRemapSession) return () => {};
return bridge.onEditorWindowRemapSession((payload) => {
editorTabStore.remapSessionId(payload.fromSessionId, payload.toSessionId);
});
}, []);
return {
activeTabId,
setActiveTabId,
closeWindow,
setWindowTitle,
};
}
@@ -1,8 +1,5 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -21,7 +18,6 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -67,44 +63,10 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
for (const savedMinutes of [null, 30]) {
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
const restore = installMemoryLocalStorage();
const manager = createSessionIdleManager();
try {
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
if (savedMinutes !== null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
}
const expected = savedMinutes ?? manager.getTimeoutMinutes();
const received: number[] = [];
const bridge = {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
savedMinutes,
'sync does not migrate or overwrite the stored preference',
);
} finally {
manager.clearAll();
restore();
}
});
}
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
@@ -1,147 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { sftpTransferCenterStore as store } from "./sftpTransferCenterStore";
import { useGlobalSftpTransferActions } from "./useGlobalSftpTransferActions";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./sftp/transferCancelLatch";
import { resetTransferRuntimeRunsForTests, transferRuntime } from "./sftp/transferRuntime";
import { resetTransferWalkRegistryForTests } from "./sftp/transferWalkRegistry";
test("Cancel all releases a skipped late failure so one Retry executes while the old walk unwinds", async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `late-failure-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
const later = tasks[32];
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const walking = transferRuntime.runWalk(later.id, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const cancellations: string[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
cancellations.push(id);
if (id === tasks[0].id) store.patchTask(later.id, { status: "failed", error: "Connection lost" });
return { success: true };
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
clearPendingTransferCancel: async () => {}, cleanupTransferArtifacts: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
let retries = 0;
store.setDedicatedResumeHandler(async () => { retries += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => { await actions!.cancelAll(); });
assert.equal(cancellations.length, 32, "the late failure must be skipped by the second batch");
assert.equal(store.getTask(later.id)?.status, "failed");
assert.equal(store.getTask(later.id)?.error, "Connection lost");
assert.equal(transferRuntime.isWalkInFlight(later.id), true);
await store.retry(later.id);
assert.equal(retries, 1, "one Retry must reach recovery instead of being swallowed by the pre-latch");
assert.equal(store.getTask(later.id)?.status, "completed");
assert.equal(isTransferOrRootCancelled(later.id), false);
});
for (const firstCancelSucceeds of [true, false]) {
test(`Cancel all dispatches later batches before walk settlement: success=${firstCancelSucceeds}`, async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `dispatch-${firstCancelSucceeds}-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
let cancelling: Promise<void> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const first = tasks[0].id;
const walking = transferRuntime.runWalk(first, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { await cancelling; renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const calls: string[] = [];
const cleaned: string[] = [];
let active = 0;
let peak = 0;
let recoveryCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
peak = Math.max(peak, ++active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== first || firstCancelSucceeds };
},
clearPendingTransferCancel: async () => {},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { recoveryCalls += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
cancelling = actions!.cancelAll();
for (let tick = 0; tick < 5; tick += 1) await new Promise((resolve) => setImmediate(resolve));
});
assert.equal(calls.length, 33, "the later active stream must receive cancellation before the first walk finishes");
assert.ok(peak > 1 && peak <= 32, "backend cancellation remains bounded");
assert.equal(transferRuntime.isWalkInFlight(first), true);
assert.equal(isTransferOrRootCancelled(first), true, "dispatch completion must not clear the old walk latch");
assert.equal(cleaned.includes(first), false, "artifact cleanup still waits for settlement");
let resumeFinished = false;
const resuming = store.resume(first).then(() => { resumeFinished = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeFinished, false, "Resume must still wait for the cancelling walk");
assert.equal(recoveryCalls, 0);
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(recoveryCalls, firstCancelSucceeds ? 0 : 1);
assert.equal(store.getTask(first)?.status, firstCancelSucceeds ? "cancelled" : "completed");
});
}
@@ -1,95 +1,28 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo } from "react";
import type { TransferTask } from "../../domain/models";
import {
getGlobalTransferBatchEligibility,
listGloballyCancellableTransferIds,
listGloballyPausableTransferIds,
listGloballyResumableTransferIds,
} from "../../domain/sftpTransferActions";
import { sftpTransferCenterStore } from "./sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler";
import { clearTransferCancelledTree, markTransferCancelledTree } from "./sftp/transferCancelLatch";
import { transferRuntime } from "./sftp/transferRuntime";
export function useGlobalSftpTransferActions(tasks: readonly TransferTask[]) {
const cancellingRef = useRef(false);
const [isCancelling, setIsCancelling] = useState(false);
const batchEligibility = useMemo(
() => getGlobalTransferBatchEligibility(tasks),
[tasks],
);
const pauseAll = useCallback(() => {
// Ignore clicks racing a cancelAll batch: re-enabling these tasks would
// restart work cancelAll has already pre-cancelled at the scheduler.
if (cancellingRef.current) return;
for (const taskId of listGloballyPausableTransferIds(tasks)) {
void sftpTransferCenterStore.pause(taskId);
}
}, [tasks]);
const resumeAll = useCallback(() => {
if (cancellingRef.current) return;
for (const taskId of listGloballyResumableTransferIds(tasks)) {
void sftpTransferCenterStore.resume(taskId);
}
}, [tasks]);
const cancelAll = useCallback(async () => {
if (cancellingRef.current) return;
cancellingRef.current = true;
setIsCancelling(true);
const currentTasks = transferRuntime.getSnapshot().tasks;
const ids = listGloballyCancellableTransferIds(currentTasks);
try {
// Stop queued work before yielding to IPC, including children of folder
// transfers. Otherwise later batches could start while earlier ones stop.
const selected = new Set(ids);
const children = new Map<string, string[]>();
for (const task of currentTasks) {
if (!task.parentTaskId || !selected.has(task.parentTaskId)
|| ["completed", "failed", "cancelled"].includes(task.status)) continue;
const idsForParent = children.get(task.parentTaskId) ?? [];
idsForParent.push(task.id);
children.set(task.parentTaskId, idsForParent);
globalSftpTransferScheduler.cancel(task.id);
}
for (const id of ids) {
markTransferCancelledTree(id, children.get(id));
globalSftpTransferScheduler.cancel(id);
}
// Bound IPC work for large queues while active transfers stop promptly.
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (taskId) => {
const current = transferRuntime.getTask(taskId);
// "failed" is terminal here just like the initial eligibility filter:
// a queued task can fail naturally while its cancel batch awaits IPC,
// and cancel's owner/orphan recovery paths would repaint the row as
// cancelled, hiding the real failure and its diagnostic.
if (!current || ["completed", "failed", "cancelled"].includes(current.status)) {
// No cancel invocation will own this pre-latch. Release it here so
// Retry is not discarded while the finished walk is unwinding.
clearTransferCancelledTree(taskId, children.get(taskId));
return;
}
try {
await transferRuntime.cancel(taskId);
} catch {
// The row is kept in attention for recovery, so the pre-installed
// cancellation latch must be dropped or a later Resume is rejected
// outright by admitTaskRun's cancelled-root check.
clearTransferCancelledTree(taskId, children.get(taskId));
sftpTransferCenterStore.patchTask(taskId, {
status: "attention",
error: "Could not cancel transfer. Please try again.",
});
}
}));
}
} finally {
cancellingRef.current = false;
setIsCancelling(false);
}
}, []);
return { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling };
return { batchEligibility, pauseAll, resumeAll };
}
@@ -1,383 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
for (const [field, value] of [
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
] as const) {
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
await api.update({ identities });
assert.equal(await api.find(), undefined);
}
});
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
});
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
assert.equal(await api.find(), undefined);
});
}
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
assert.equal(await api.find(), undefined);
});
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, username: 'other-serial-user' } });
assert.equal(await api.find(), undefined);
});
for (const [field, value] of [
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
] as const) {
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
const api = await harness(original, { sessions: [session] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
t.after(alreadyEdited.close);
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
});
}
test('local default shell, cwd and empty args match the renderer launch contract', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const api = await harness(original);
t.after(api.close);
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShellArgs: [] } });
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
});
for (const [label, proxy] of [
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
] as const) {
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
const original = host({ protocol: 'telnet' });
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, ...proxy } });
assert.equal(await api.find(), undefined);
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
assert.equal((await api.find())?.sessionId, 'tab');
});
}
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
const original = host({ protocol: 'telnet', group: 'prod/web' });
const groupConfigs: GroupConfig[] = [];
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
await api.update({ host: { ...original } });
assert.equal(await api.find(), undefined);
});
for (const owner of ['saved', 'global'] as const) {
for (const [field, changed] of [
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
] as const) {
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = {
terminalSettings: settings,
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
if (owner === 'global') Object.assign(settings, { [field]: changed });
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, '/bin/bash');
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const settings: { localShell?: string; localStartDir?: string } = {};
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, '');
assert.equal(launched.localStartDir, '~');
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined);
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
});
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
const context = {
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
assert.equal(launched.localShell, '/bin/bash');
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
assert.equal(defaultLaunch.localShell, '');
assert.equal(defaultLaunch.localShellArgs, undefined);
});
for (const discovered of [
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
]) {
for (const owner of ['saved', 'global'] as const) {
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, discovered.id);
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
context.discoveredShells = [discovered];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, discovered.id);
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
});
@@ -1,149 +0,0 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
type HostOpenSessionReuseInput = {
sessions: TerminalSession[];
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
resolveEffectiveHost: (host: Host) => Host;
};
type OpeningConnection = { localHost: Host } | { key: Promise<string | undefined> };
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, OpeningConnection>());
const connectionKey = useCallback((host: Host) => {
const protocol = resolveEffectiveTerminalProtocol(host);
if (protocol === 'local') {
const options = {
hostname: host.hostname,
username: '',
localShell: host.localShell || undefined,
// The renderer starter omits both absent and empty args so the
// backend chooses its discovered/login defaults for either form.
localShellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
localStartDir: host.localStartDir || '~',
};
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
}
if (protocol === 'telnet') {
// The actual Telnet starter rejects these settings before connecting.
if ((host.proxyProfileId && !host.proxyConfig)
|| findMissingProxyIdentityId(host.proxyConfig, identities)
|| findIncompleteProxyIdentityId(host.proxyConfig, identities)
|| hasUsableProxyConfig(host.proxyConfig)) {
throw new Error('Telnet proxy configuration cannot be used');
}
// Match the actual Telnet starter, including explicit empty overrides.
const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined;
if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) {
throw new Error('Telnet identity is missing or incomplete');
}
const rawPassword = identity ? identity.password : resolveTelnetPassword(host);
if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable');
const options = {
hostname: host.hostname,
port: resolveTelnetPort(host),
username: identity ? identity.username?.trim() : resolveTelnetUsername(host),
password: sanitizeCredentialValue(rawPassword),
};
return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options });
}
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable');
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
delete options.sudo;
delete options.fileProtocol;
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol,
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
etJumpPorts: protocol === 'et'
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
: undefined,
pluginConnection: host.pluginConnection,
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
}, [hosts, keys, identities, resolveEffectiveHost]);
useLayoutEffect(() => {
const liveIds = new Set(sessions.map((session) => session.id));
for (const id of openingKeys.current.keys()) {
if (!liveIds.has(id)) openingKeys.current.delete(id);
}
for (const session of sessions) {
if (openingKeys.current.has(session.id) || session.restoreState) continue;
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
const effectiveHost = resolveEffectiveHost(host);
const openingHost = {
...effectiveHost,
hostname: session.hostname,
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
localShell: session.localShell ?? effectiveHost.localShell,
localShellArgs: session.localShellArgs ?? effectiveHost.localShellArgs,
localStartDir: session.localStartDir ?? effectiveHost.localStartDir,
};
// Local IDs may only resolve after renderer discovery completes. Keep
// the opening settings so that later discovery can identify the same
// executable without replacing them with edited vault/global settings.
openingKeys.current.set(session.id, resolveEffectiveTerminalProtocol(openingHost) === 'local'
? { localHost: openingHost }
: { key: connectionKey(openingHost).catch(() => undefined) });
} catch {
openingKeys.current.set(session.id, { key: Promise.resolve(undefined) });
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
return useCallback(async (hostId: string) => {
const host = hosts.find((candidate) => candidate.id === hostId);
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => {
if (session.hostId !== hostId) return undefined;
const opening = openingKeys.current.get(session.id);
const openingKey = opening && ('localHost' in opening
// An explicit empty selector means backend default, not today's
// global setting. Nonempty captured IDs can safely resolve now.
? connectionKey(opening.localHost.localShell ? resolveEffectiveHost(opening.localHost) : opening.localHost)
: opening.key);
return await openingKey === requestedKey ? session : undefined;
}));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
return undefined;
}
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
}
@@ -1,24 +0,0 @@
import { useEffect, useMemo } from 'react';
import {
createSessionHistorySearch,
type SessionHistorySearch,
} from './sessionHistorySearch';
/**
* Owns one search instance per mounted session history drawer.
*
* The search index cache is bound to this instance (not module scope), so
* several concurrently mounted drawers — one per terminal tab — each keep
* their own indexes: pruning in one drawer never drops another drawer's
* entries, and the unmount below releases only this drawer's store instead of
* discarding the shared cache for every drawer. Cache keys are the session
* objects themselves, so clearing on unmount drops all strong session
* references together with the retained haystack characters.
*/
export function useSessionHistorySearch(): SessionHistorySearch {
const search = useMemo(() => createSessionHistorySearch(), []);
useEffect(() => () => {
search.clearSessionHistorySearchCache();
}, [search]);
return search;
}
+11 -6
View File
@@ -1098,12 +1098,17 @@ export const useSessionState = ({
if (targetHosts.length === 0) return;
const resolvedCommand = commandOverride ?? snippet.command;
// Create sessions for each target host. createHostTerminalSession copies
// protocol, shellType, and local-shell metadata, so local vault hosts keep
// their transport info (session consumers treat a missing protocol as SSH).
const newSessions: TerminalSession[] = targetHosts.map(host =>
createHostTerminalSession(crypto.randomUUID(), host),
);
// Create sessions for each target host
const newSessions: TerminalSession[] = targetHosts.map(host => ({
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
const sessionIds = newSessions.map(s => s.id);
-4
View File
@@ -151,7 +151,6 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -356,9 +355,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
-2
View File
@@ -53,7 +53,6 @@ export interface UseVaultAgentBridgeInput {
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -272,7 +271,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+10 -52
View File
@@ -80,7 +80,6 @@ import {
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
@@ -307,12 +306,6 @@ export const useVaultState = () => {
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const notesStorageRef = useRef<VaultNote[]>([]);
// An optimistic React echo is not a persistence acknowledgement. Keep this
// owner until that exact generation has successfully reached storage.
const notesPendingRef = useRef<VaultNote[] | null>(null);
// Failed clear/import keeps full-snapshot ownership across later local edits.
const notesPendingReplaceRef = useRef(false);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
@@ -841,37 +834,17 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[], options?: { replace?: boolean }) => {
const stored = normalizeVaultNotes(localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES) ?? []);
const requested = normalizeVaultNotes(data);
// Explicit clear/restore keeps its existing full-snapshot semantics.
const replace = options?.replace === true || notesPendingReplaceRef.current;
const pending = replace ? requested : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: requested,
theirs: stored,
});
notesStorageRef.current = stored;
notesPendingRef.current = pending;
notesPendingReplaceRef.current = replace;
notesRef.current = pending;
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data: pending,
data,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
if (notesPendingRef.current === pending) {
notesRef.current = cleaned;
if (persisted) {
notesStorageRef.current = cleaned;
notesPendingRef.current = null;
notesPendingReplaceRef.current = false;
}
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}
notesRef.current = cleaned;
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
@@ -1190,7 +1163,7 @@ export const useVaultState = () => {
updateProxyProfiles([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([], { replace: true });
updateNotes([]);
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
@@ -1561,10 +1534,8 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes && notesPendingRef.current === null) {
if (savedNotes) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
notesStorageRef.current = cleanedNotes;
notesRef.current = cleanedNotes;
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
@@ -1809,21 +1780,8 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_NOTES) {
// Like snippets, ignore queued events for a snapshot no longer on disk.
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_NOTES)) return;
// Only a successful current write can release an outstanding replacement.
if (notesPendingReplaceRef.current) return;
const next = normalizeVaultNotes(safeParse<VaultNote[]>(event.newValue) ?? []);
const pending = notesPendingRef.current;
const visible = pending === null ? next : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: pending,
theirs: next,
});
notesStorageRef.current = next;
if (pending !== null) notesPendingRef.current = visible;
notesRef.current = visible;
setNotes(visible);
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
return;
}
@@ -1994,7 +1952,7 @@ export const useVaultState = () => {
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes, { replace: true });
if (payload.notes) updateNotes(payload.notes);
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
-44
View File
@@ -1667,50 +1667,6 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
try {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { autoCloseOnExit: true } },
}, { importVaultData: () => {} });
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(resurrected.autoCloseOnExit, false);
assert.equal(resurrected.scrollback, 3000);
// An explicit opt-in made after the migration round-trips with a marker.
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
);
const optedIn = buildSyncPayload(vault());
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload(optedIn, { importVaultData: () => {} });
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restoredChoice.autoCloseOnExit, true);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
}
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
-22
View File
@@ -25,7 +25,6 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -63,7 +62,6 @@ import {
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -507,9 +505,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
settings.autoCloseOnExitDefaultMigrated = true;
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
@@ -800,23 +795,6 @@ async function applySyncableSettings(
? 'disabled'
: 'paste';
}
if ('autoCloseOnExit' in settings.terminalSettings) {
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
settings.terminalSettings.autoCloseOnExit,
settings.autoCloseOnExitDefaultMigrated,
);
}
// A legacy true is the old default. A migrated peer's explicit choice must
// stick, so record the marker before the next local migration pass.
if (
settings.autoCloseOnExitDefaultMigrated === true
|| (
settings.terminalSettings.autoCloseOnExit === true
&& settings.autoCloseOnExitDefaultMigrated !== true
)
) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
+1 -23
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus, Undo2 } from 'lucide-react';
import { History, Plus } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -55,9 +55,6 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -135,8 +132,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
@@ -237,23 +232,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -1,126 +0,0 @@
import assert from "node:assert/strict";
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 { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
const dom = installDomEnvironment();
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(dom.document);
const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({
id: `s-${i}`,
title: `Session ${i}`,
agentId: "catty",
scope: { type: "terminal", targetId: "terminal-1" },
messages: [{
id: `m-${i}`, role: "user", timestamp: 0,
content: i === 140 ? "needle [prod].json" : "ordinary content",
}],
createdAt: 0,
updatedAt: 0,
}));
const selected: string[] = [];
const deleted: string[] = [];
let closed = 0;
const render = (items = sessions, locale = "en") => renderer.render(
<I18nProvider locale={locale}>
<TooltipProvider>
<SessionHistoryDrawer
sessions={items}
activeSessionId="s-140"
onSelect={(id) => selected.push(id)}
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
onClose={() => { closed += 1; }}
/>
</TooltipProvider>
</I18nProvider>,
);
const rows = () => renderer.container.querySelectorAll('[role="button"]');
const click = async (element: Element | null | undefined) => {
assert.ok(element);
await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true }));
};
const enterQuery = async (query: string) => {
const input = renderer.container.querySelector("input");
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
setValue.call(input, query);
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
};
const loadMore = () => [...renderer.container.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Load more"));
try {
await render();
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await click(loadMore());
assert.equal(rows().length, 140);
await enterQuery("[prod].json");
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
assert.equal(loadMore(), undefined);
await click(rows()[0]);
await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await click(rows()[0].querySelector("button"));
assert.deepEqual(selected, ["s-140", "s-140"]);
assert.deepEqual(deleted, ["s-140"]);
// A pasted suffix must not remain visible while the matcher silently
// ignores it: the controlled input and search accept the same prefix.
const pastedQuery = "needle ".repeat(36) + "needle absent-tail";
await enterQuery(pastedQuery);
const boundedInput = renderer.container.querySelector("input")!;
assert.equal(boundedInput.value, pastedQuery.slice(0, 256));
assert.equal(boundedInput.maxLength, 256);
assert.ok(!boundedInput.value.includes("absent-tail"));
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
await enterQuery("needle absent-tail");
assert.equal(boundedInput.value, "needle absent-tail");
assert.equal(rows().length, 0, "every visible query token must affect matching");
await enterQuery("x".repeat(255) + "\u{1f63a}");
assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair");
await enterQuery("[](){}");
assert.equal(rows().length, 0);
assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/);
assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/);
assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/);
assert.match(renderer.container.textContent ?? "", /not full-text search/);
await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button"));
assert.equal(renderer.container.querySelector("input")?.value, "");
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await enterQuery(" \t");
assert.equal(rows().length, 80);
await click(renderer.container.querySelector("button"));
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = MESSAGES_BY_LOCALE[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"]) {
assert.ok(messages[key], `${locale} is missing ${key}`);
}
assert.equal(input.placeholder, messages["ai.chat.searchSessions"]);
assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]);
assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
await render([], locale);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"]));
assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
}
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+5 -64
View File
@@ -1,14 +1,11 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch';
// -------------------------------------------------------------------
// Session History Drawer
@@ -34,28 +31,13 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
// One cache instance per mounted drawer: caches of concurrently mounted
// drawers stay independent (no shared state to prune or discard across tabs).
const search = useSessionHistorySearch();
const filteredSessions = React.useMemo(
// The localized fallback below is displayed for untitled sessions
// (row label), so it must be indexed for search as the displayed title.
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
[sessions, searchQuery, t, search],
);
// Reset the render batch when the list scope or query changes so matching
// sessions are never hidden behind stale paging.
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions, searchQuery]);
}, [sessions]);
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -68,41 +50,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<X size={14} />
</button>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
/>
<Input
ref={searchInputRef}
type="text"
value={searchQuery}
maxLength={MAX_SEARCH_QUERY_LENGTH}
placeholder={t('ai.chat.searchSessions')}
aria-label={t('ai.chat.searchSessions')}
aria-describedby={searchHintId}
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
className="h-8 pl-8 pr-7 text-[12px]"
/>
{searchQuery && (
<button
type="button"
aria-label={t('ai.chat.clearSessionSearch')}
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
{t('ai.chat.searchSessionsHint')}
</p>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
@@ -111,12 +58,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.noSessions')}
</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noMatchingSessions')}
</p>
</div>
) : (
<>
{hiddenSessionCount > 0 && (
@@ -120,9 +120,7 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(panel, /sending=\{isSending\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+4 -93
View File
@@ -65,7 +65,6 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -270,7 +269,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -321,11 +319,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
@@ -828,78 +821,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -1256,8 +1177,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
@@ -1393,10 +1314,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1521,7 +1438,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1536,7 +1452,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1550,8 +1465,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1633,7 +1546,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1921,7 +1834,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending || isUndoingLastTurn}
sending={isSending}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1951,8 +1864,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
-4
View File
@@ -37,10 +37,6 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
+2 -17
View File
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../domain/models";
import { listGloballyCancellableTransferIds } from "../domain/sftpTransferActions";
import {
buildGlobalTransferProgressDisplay,
getGlobalConflictActionPresentation,
@@ -66,28 +65,14 @@ test("batch actions are disabled when no task can accept them", () => {
assert.deepEqual(getGlobalTransferBatchEligibility([
task("done", "completed"),
task("cancelled", "cancelled"),
]), { cancellableCount: 0, pausableCount: 0, resumableCount: 0 });
]), { pausableCount: 0, resumableCount: 0 });
assert.deepEqual(getGlobalTransferBatchEligibility([
task("running", "transferring"),
task("waiting", "queued"),
task("paused", "paused"),
{ ...task("failed", "failed"), checkpointBytes: 4 },
]), { cancellableCount: 3, pausableCount: 2, resumableCount: 2 });
});
test("cancel all selects unfinished top-level tasks without touching completed files", () => {
const statuses: TransferTask["status"][] = [
"pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention",
];
const tasks = [
...statuses.map((status) => task(status, status)),
task("completed", "completed"),
task("failed", "failed"),
task("cancelled", "cancelled"),
{ ...task("child", "queued"), parentTaskId: "pending" },
];
assert.deepEqual(listGloballyCancellableTransferIds(tasks), statuses);
]), { pausableCount: 2, resumableCount: 2 });
});
test("paused transfer errors remain visible instead of looking safely paused", () => {
+4 -7
View File
@@ -837,7 +837,7 @@ export function GlobalSftpTransferCenter() {
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]);
const { visible, collapsed } = splitBackgroundTransfers(bucketTasks);
const displayed = showBackground ? [...visible, ...collapsed] : visible;
const { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling } = useGlobalSftpTransferActions(snapshot.tasks);
const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -877,16 +877,13 @@ export function GlobalSftpTransferCenter() {
<div className="min-w-0 pr-2 text-sm font-semibold">
{t("sftp.transferCenter.title")}
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={isCancelling || batchEligibility.pausableCount === 0}>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={isCancelling || batchEligibility.resumableCount === 0}>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-destructive" onClick={() => { void cancelAll(); }} disabled={isCancelling || batchEligibility.cancellableCount === 0}>
<X size={12} className="mr-1" />{t("sftp.transferCenter.cancelAll")}
</Button>
</div>
</div>
+1 -7
View File
@@ -25,7 +25,6 @@ import {
formatProxyConfigType,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
import {
EnvVar,
GroupConfig,
@@ -334,12 +333,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
!chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
return allHosts.filter((h) => !chainedIds.has(h.id));
}, [allHosts, form.hostChain?.hostIds]);
const addHostToChain = (hostId: string) => {
+1 -8
View File
@@ -23,7 +23,6 @@ import {
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth";
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
import { customThemeStore } from "../application/state/customThemeStore";
import {
hasHostFontSizeOverride,
@@ -750,13 +749,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
h.id !== form.id
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
}, [allHosts, form.id, form.hostChain?.hostIds]);
const chainedHosts = useMemo(() => {
+1 -5
View File
@@ -16,7 +16,6 @@ import {
type HostClickBehavior,
} from '../domain/hostClickBehavior';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -727,7 +726,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
initialTreeItemKey,
onActiveTreeItemChange,
}) => {
const { t } = useI18n();
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const displayDetails = useMemo(
@@ -845,9 +843,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<HostNotesIndicator notes={host.notes} />
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displayProtocol === 'local'
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
: `${displayUsername}@${host.hostname}:${displayPort}`}
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
)}
@@ -1,101 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { LocalShellHostDetailsPanel } from "./LocalShellHostDetailsPanel.tsx";
import type { Host } from "../types.ts";
const panelSource = readFileSync(
new URL("./LocalShellHostDetailsPanel.tsx", import.meta.url),
"utf8",
);
const existingLocalHost = {
id: "local-existing",
label: "Project A CMD",
hostname: "localhost",
username: "",
tags: ["local"],
group: "Projects/A",
protocol: "local",
os: "linux",
createdAt: 0,
localShell: "powershell",
localShellName: "Windows PowerShell",
localShellIcon: "powershell",
} as unknown as Host;
const noop = () => {};
test("renderToStaticMarkup: create mode shows title, default-shell option and Save & Connect", () => {
const markup = renderToStaticMarkup(
<LocalShellHostDetailsPanel
defaultGroup="Projects/A"
groups={["Projects/A", "Infra"]}
onSave={noop}
onSaveAndConnect={noop}
onCancel={noop}
layout="inline"
/>,
);
assert.match(markup, /localShell\.panel\.title/);
assert.match(markup, /localShell\.field\.shellDefault/);
assert.match(markup, /localShell\.saveAndConnect/);
assert.match(markup, /localShell\.saveAndConnect|common\.save/);
});
test("renderToStaticMarkup: edit mode hides Save & Connect and shows existing shell", () => {
const markup = renderToStaticMarkup(
<LocalShellHostDetailsPanel
initialData={existingLocalHost}
groups={["Projects/A"]}
onSave={noop}
onCancel={noop}
layout="inline"
/>,
);
assert.match(markup, /localShell\.panel\.title\.edit/);
assert.doesNotMatch(markup, /localShell\.saveAndConnect/);
// Existing label is carried into the form
assert.match(markup, /Project A CMD/);
assert.match(markup, /local-shell-start-dir/);
});
test("buildHost preserves the saved OS on edits and detects it only for new entries", () => {
// Editing must retain initialData.os: entries imported/synced on one
// platform must not be re-classified as the editor machine's OS on save.
assert.match(
panelSource,
/os:\s*initialData\?\.os \?\? detectLocalOs\(navigator\.userAgent \|\| navigator\.platform\)/,
);
});
test("unchanged shell selection keeps stored metadata over discovery", () => {
// A host with custom localShellArgs for a shell id that discovery also
// matches must keep its stored args/name/icon when an unrelated edit
// (label, tag, group) is saved; discovered metadata applies only after
// the user changes the shell selection.
const unchanged = panelSource.match(
/const shellMeta =\s*\n[\s\S]*?shell === initialData\?\.localShell\s*\n\s*\?\s*\{[\s\S]*?\}\s*\n\s*: matchedShell;/,
);
assert.ok(unchanged, "shellMeta must branch on an unchanged shell selection");
// Inside the unchanged branch, stored metadata wins per field and
// matchedShell is only a fallback (never the primary source).
assert.match(
unchanged[0],
/args:\s*initialData\?\.localShellArgs \?\? matchedShell\?\.args/,
);
assert.match(
unchanged[0],
/name:\s*initialData\?\.localShellName \?\? matchedShell\?\.name/,
);
assert.match(
unchanged[0],
/icon:\s*initialData\?\.localShellIcon \?\? matchedShell\?\.icon/,
);
assert.doesNotMatch(unchanged[0], /matchedShell \?\?/);
});
-255
View File
@@ -1,255 +0,0 @@
/**
* Local Shell Host Details Panel
* A dedicated editor for local shell / CMD vault hosts (protocol 'local'),
* distinct from the SSH HostDetailsPanel. Lets users save named local shells
* into vault groups next to their SSH hosts (issue #3615).
*/
import { Save, TerminalSquare, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useDiscoveredShells } from '../lib/useDiscoveredShells';
import { detectLocalOs } from '../lib/localShell';
import type { Host } from '../domain/models';
import { createLocalShellHost } from '../domain/localShellHost';
import { Button } from './ui/button';
import { Combobox, ComboboxOption } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from './ui/aside-panel';
import { cn } from '../lib/utils';
interface LocalShellHostDetailsPanelProps {
/** Existing host when editing; null/undefined when creating a new entry. */
initialData?: Host | null;
defaultGroup?: string;
groups?: string[];
onSave: (host: Host) => void;
/** Saves the host and immediately opens a terminal for it. */
onSaveAndConnect?: (host: Host) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
className?: string;
}
type LocalShellHostDetailsPanelPropsWithResize =
LocalShellHostDetailsPanelProps & AsidePanelResizeProps;
export const LocalShellHostDetailsPanel: React.FC<LocalShellHostDetailsPanelPropsWithResize> = ({
initialData,
defaultGroup,
groups = [],
onSave,
onSaveAndConnect,
onCancel,
layout = 'overlay',
className,
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const [label, setLabel] = useState(initialData?.label ?? '');
const [shell, setShell] = useState(initialData?.localShell ?? '');
const [startDir, setStartDir] = useState(initialData?.localStartDir ?? '');
// On creation, initialize from defaultGroup so the field reflects what a
// plain save would persist; clearing it then intentionally saves at the
// vault root. Edits keep the host's own group (possibly empty).
const [group, setGroup] = useState(
initialData ? initialData.group ?? '' : defaultGroup ?? '',
);
const matchedShell = useMemo(
() => discoveredShells.find((candidate) => candidate.id === shell),
[discoveredShells, shell],
);
// While the shell selection is unchanged from the saved host, prefer the
// host's stored metadata: an imported/synced host may carry custom startup
// arguments for a shell id that also exists in discovery, and saving an
// unrelated edit (label, tag, group) must not overwrite them with the
// current discovery result. Discovered metadata applies only after the user
// changes the selection — or per-field when the host stores none.
const shellMeta =
shell && shell === initialData?.localShell
? {
args: initialData?.localShellArgs ?? matchedShell?.args,
name: initialData?.localShellName ?? matchedShell?.name,
icon: initialData?.localShellIcon ?? matchedShell?.icon,
}
: matchedShell;
const shellOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{ value: '', label: t('localShell.field.shellDefault') },
];
for (const candidate of discoveredShells) {
options.push({
value: candidate.id,
label: candidate.name,
sublabel: candidate.command,
});
}
return options;
}, [discoveredShells, t]);
const groupOptions: ComboboxOption[] = useMemo(() => {
const allGroups = new Set(groups);
if (group && !allGroups.has(group)) {
allGroups.add(group);
}
return Array.from(allGroups).map((g) => ({
value: g,
label: g,
}));
}, [groups, group]);
const buildHost = (): Host => {
const host = createLocalShellHost({
id: initialData?.id,
// Keep the entry's saved OS on edits: importing/syncing on one platform
// then editing elsewhere must not silently re-classify the host (shell
// defaults, CSV round trips, dedup) as the editor machine's OS.
os: initialData?.os ?? detectLocalOs(navigator.userAgent || navigator.platform),
label,
shell,
shellArgs: shellMeta?.args,
shellName: shellMeta?.name,
shellIcon: shellMeta?.icon,
startDir,
// Save the controlled value directly: '' means the user explicitly
// cleared the group, which persists at the vault root.
group: group,
tags: initialData?.tags,
notes: initialData?.notes,
});
// On edits, merge over the existing host so fields this form does not
// expose (pinned, lastConnectedAt, order, ...) and the original
// creation timestamp survive a save.
return initialData
? { ...initialData, ...host, createdAt: initialData.createdAt ?? host.createdAt }
: host;
};
const handleSave = () => onSave(buildHost());
const handleSaveAndConnect = () => {
if (!onSaveAndConnect) return;
onSaveAndConnect(buildHost());
};
return (
<AsidePanel
open={true}
onClose={onCancel}
title={initialData ? t('localShell.panel.title.edit') : t('localShell.panel.title')}
subtitle={initialData?.label}
className={cn('z-40', className)}
layout={layout}
dataSection="local-shell-host-details-panel"
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
>
<AsidePanelContent>
{/* Label */}
<div className="space-y-2">
<Label htmlFor="local-shell-label">{t('localShell.field.label')}</Label>
<Input
id="local-shell-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('localShell.field.labelPlaceholder')}
/>
</div>
{/* Shell */}
<div className="space-y-2">
<Label>{t('localShell.field.shell')}</Label>
<Combobox
options={shellOptions}
value={shell}
onValueChange={setShell}
placeholder={t('localShell.field.shellDefault')}
emptyText={t('localShell.field.shellEmpty')}
allowCreate
createText={t('common.use')}
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{matchedShell
? t('localShell.field.shellResolved', { command: matchedShell.command })
: t('localShell.field.shellDefaultDesc')}
</p>
</div>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>
<Combobox
options={groupOptions}
value={group}
onValueChange={setGroup}
placeholder={t('hostDetails.selectGroup')}
allowCreate
createText={t('hostDetails.createGroup')}
/>
</div>
{/* Start directory */}
<div className="space-y-2">
<Label htmlFor="local-shell-start-dir">{t('localShell.field.startDir')}</Label>
<div className="relative">
<Input
id="local-shell-start-dir"
value={startDir}
onChange={(e) => setStartDir(e.target.value)}
placeholder={t('localShell.field.startDirPlaceholder')}
className="pr-8"
/>
{startDir && (
<button
type="button"
onClick={() => setStartDir('')}
aria-label={t('common.reset')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
)}
</div>
</div>
</AsidePanelContent>
<AsidePanelFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</Button>
{onSaveAndConnect && !initialData && (
<Button
onClick={handleSaveAndConnect}
className="flex-1"
>
<TerminalSquare size={14} className="mr-2" />
{t('localShell.saveAndConnect')}
</Button>
)}
<Button onClick={handleSave} className="flex-1">
<Save size={14} className="mr-2" />
{t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
};
export default LocalShellHostDetailsPanel;
-4
View File
@@ -30,8 +30,6 @@ interface SelectHostPanelProps {
resizable?: boolean;
persistWidthStorageKey?: string;
resizeAriaLabel?: string;
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
allowLocalHosts?: boolean;
}
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
@@ -58,7 +56,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
allowLocalHosts = false,
}) => {
const { t } = useI18n();
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
@@ -106,7 +103,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
onNewHostPanelOpenChange={setNewHostPanelOpen}
allowLocalHosts={allowLocalHosts}
className="flex-1 min-h-0"
/>
</AsidePanel>
+2 -10
View File
@@ -58,8 +58,6 @@ export interface SelectHostPanelContentProps {
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
onNewHostPanelOpenChange?: (open: boolean) => void;
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
allowLocalHosts?: boolean;
className?: string;
}
@@ -87,7 +85,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSaveHost,
onCreateGroup,
onNewHostPanelOpenChange,
allowLocalHosts = false,
className,
}) => {
const { t } = useI18n();
@@ -109,14 +106,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onNewHostPanelOpenChange?.(showNewHostPanel);
}, [onNewHostPanelOpenChange, showNewHostPanel]);
// This panel picks hosts for SSH-backed capabilities (tunnels, keychain
// key export targets); serial hosts have no SSH session to target and, by
// default, neither do local-shell hosts. Snippet/script target pickers pass
// `allowLocalHosts` because their domain treats every non-serial host
// (including saved CMD/PowerShell shells) as a runnable target.
const selectableHosts = useMemo(
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
[hosts, allowLocalHosts],
() => hosts.filter((host) => host.protocol !== 'serial'),
[hosts],
);
const selectedHostIdSet = useMemo(
() => new Set(selectedHostIds),
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more