Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee97b508f1 | ||
|
|
e57eeebc1e | ||
|
|
6c2bf43ee5 | ||
|
|
abdf254702 | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec | ||
|
|
71373d1ad1 | ||
|
|
b4e056dd4a | ||
|
|
d5c7d3fbf4 | ||
|
|
1ab07ffee4 | ||
|
|
4af4af274b | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 | ||
|
|
483410b132 | ||
|
|
201ebcb21a | ||
|
|
6ac6c3dbfa | ||
|
|
11f2b7dbd8 | ||
|
|
af574078e7 | ||
|
|
c89e4c7246 | ||
|
|
282a557f67 | ||
|
|
7cb3e2e2ad | ||
|
|
90ab87efcc | ||
|
|
4cd6a7191d | ||
|
|
3a0528bbd4 | ||
|
|
c349d4716f | ||
|
|
f7483f0593 | ||
|
|
1209bfa61c | ||
|
|
6baa87e3c3 | ||
|
|
a1d1115e0a | ||
|
|
16376a2117 | ||
|
|
f0833049e0 | ||
|
|
75c155b748 | ||
|
|
6fcd6a3ec3 | ||
|
|
de6d1a28ba | ||
|
|
b2ed83ae51 | ||
|
|
f91b2cd04e | ||
|
|
1d10d1679e | ||
|
|
8d2058267a | ||
|
|
8020eaa427 | ||
|
|
3298a70f32 | ||
|
|
e7202f392a | ||
|
|
9423a1d1cd | ||
|
|
50ca182e1e | ||
|
|
04a63c653a | ||
|
|
e2f499b0f9 | ||
|
|
5805d15746 | ||
|
|
bd8476fb34 | ||
|
|
c74daf0102 | ||
|
|
f1425c8d5a | ||
|
|
fc55e9ac17 | ||
|
|
025ce741c2 | ||
|
|
323ff0b16a | ||
|
|
6ece7e8222 | ||
|
|
e3894eb850 | ||
|
|
38c7376515 | ||
|
|
1b8228b23d | ||
|
|
b2c518a6f1 | ||
|
|
ea36f46d1d | ||
|
|
6d0d39e678 | ||
|
|
08089323a9 | ||
|
|
91db655a1a | ||
|
|
d51cd2714d | ||
|
|
3fd954d2bf | ||
|
|
ff28c9b412 | ||
|
|
7006fa214e | ||
|
|
459f3a5278 | ||
|
|
6c8edddfa8 | ||
|
|
5af2c86f8f | ||
|
|
7b1151f976 | ||
|
|
d21016b91a | ||
|
|
b62a0b3a8f | ||
|
|
3560a04fa6 | ||
|
|
a373f57905 | ||
|
|
e376190a70 | ||
|
|
037a2f651b | ||
|
|
1a4a022a8e | ||
|
|
fd177e8453 | ||
|
|
dee468dcfa | ||
|
|
c666fdb156 | ||
|
|
b4111cf970 | ||
|
|
69497ccc6a | ||
|
|
078353dc87 | ||
|
|
fb7adc3bfc | ||
|
|
8d6ef72006 | ||
|
|
85683757e5 | ||
|
|
7224ad37c8 | ||
|
|
598f5cf796 | ||
|
|
ed92944af0 | ||
|
|
e2b3daac9a | ||
|
|
85bddba538 | ||
|
|
66e87a95f1 | ||
|
|
7cce9c9dbb | ||
|
|
954b60acf2 | ||
|
|
73284582f5 | ||
|
|
33853398d4 | ||
|
|
9497cd65fc | ||
|
|
95a0186d90 | ||
|
|
626e0ce5bd | ||
|
|
71d378c9b8 | ||
|
|
105b7f16bc | ||
|
|
ec11989d52 | ||
|
|
fd3e82386a | ||
|
|
2b120c0d41 | ||
|
|
318577cd72 | ||
|
|
6398360854 | ||
|
|
e3b2e82f88 | ||
|
|
9e07e9e7b9 | ||
|
|
0261931f95 | ||
|
|
62e9c9ed12 | ||
|
|
1b4e826e15 | ||
|
|
f469513579 | ||
|
|
707972d976 | ||
|
|
8573027e61 | ||
|
|
ff65339e04 | ||
|
|
a70c43e302 | ||
|
|
7b3b8214a9 |
No files matched your search
@@ -1202,8 +1202,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Freeze trusted helper
|
||||
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
|
||||
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
|
||||
run: *freeze_ai_helper
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
|
||||
@@ -70,3 +70,26 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -398,3 +399,69 @@ 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);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -71,6 +72,16 @@ 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,
|
||||
|
||||
@@ -3,7 +3,8 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
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';
|
||||
@@ -204,7 +205,10 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -535,7 +539,11 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
||||
try {
|
||||
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
||||
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
||||
allowSshProbe = shouldProbeSessionCwd({
|
||||
isNetworkDevice: false,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
} catch {
|
||||
allowSshProbe = false;
|
||||
}
|
||||
@@ -1100,11 +1108,15 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1134,7 +1146,10 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1182,8 +1197,13 @@ 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);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ 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 }),
|
||||
@@ -71,8 +82,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, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -81,7 +92,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, 2);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -22,14 +23,16 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: 'standard';
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -160,7 +163,18 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{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' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("deep-link tab names are applied to terminal sessions", () => {
|
||||
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
|
||||
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
|
||||
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
|
||||
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
|
||||
);
|
||||
assert.match(source, /label: tabName/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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,
|
||||
@@ -37,6 +38,7 @@ 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';
|
||||
@@ -52,6 +54,7 @@ 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';
|
||||
@@ -98,6 +101,11 @@ type OpenSessionInNewWindowPayload = {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
type DeepLinkPayload = {
|
||||
url?: string;
|
||||
tabName?: string;
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
|
||||
@@ -260,24 +268,9 @@ export function AppSideEffects() {
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
getSessionRestoreCwd,
|
||||
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();
|
||||
|
||||
@@ -635,6 +628,8 @@ export function AppSideEffects() {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
useEffect(() => installEditorWindowSourceListeners(), []);
|
||||
|
||||
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
@@ -1357,8 +1352,32 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
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],
|
||||
);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1389,14 +1408,25 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (sessionId && tabName) {
|
||||
renameSessionInline(sessionId, tabName);
|
||||
}
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
@@ -1415,6 +1445,10 @@ 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.` };
|
||||
@@ -1457,6 +1491,7 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
@@ -1465,15 +1500,16 @@ export function AppSideEffects() {
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
@@ -1502,25 +1538,26 @@ export function AppSideEffects() {
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
const hostDraft = buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
});
|
||||
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
@@ -1537,9 +1574,10 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
@@ -1560,10 +1598,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1572,10 +1610,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
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 } from '../state/editorTabStore';
|
||||
import { editorTabStore, tabIsDirty } 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';
|
||||
@@ -36,6 +37,7 @@ 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';
|
||||
@@ -353,14 +355,16 @@ 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]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
@@ -420,6 +424,23 @@ 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;
|
||||
@@ -431,7 +452,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
@@ -462,6 +483,16 @@ 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;
|
||||
@@ -538,6 +569,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -788,7 +821,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
))}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
@@ -797,6 +830,7 @@ 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, 4, 'main, settings, tray and terminal-popup roots');
|
||||
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
|
||||
@@ -28,35 +28,3 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,31 +70,23 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -105,21 +97,19 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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");
|
||||
});
|
||||
@@ -357,6 +357,7 @@ 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) {
|
||||
|
||||
@@ -4,6 +4,8 @@ export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.modelCatalogRetry': 'Retry',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
@@ -287,6 +289,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': "Xiaomi's MiMo Code CLI, an OpenCode fork. Install it and configure providers in its own config, then select it as an external coding agent.",
|
||||
'ai.mimo.detecting': 'Detecting...',
|
||||
'ai.mimo.detected': 'Detected',
|
||||
'ai.mimo.notFound': 'Not found',
|
||||
'ai.mimo.path': 'Path:',
|
||||
'ai.mimo.notFoundHint': 'Could not find mimo in PATH. Install it or specify the executable path below.',
|
||||
'ai.mimo.customPathPlaceholder': 'e.g. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Check',
|
||||
'ai.mimo.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
@@ -323,6 +337,13 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Always',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
|
||||
'ai.externalMcp.allowAll': 'Allow all actions',
|
||||
'ai.externalMcp.allowSession': 'This connection',
|
||||
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
|
||||
'ai.externalMcp.allowHost': 'This server',
|
||||
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
@@ -501,12 +522,18 @@ 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.',
|
||||
@@ -600,8 +627,16 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.movePanelBottom': 'Move panel to bottom',
|
||||
'terminal.layer.choosePanelPosition': 'Change position',
|
||||
'terminal.layer.dockLeft': 'Left',
|
||||
'terminal.layer.dockRight': 'Right',
|
||||
'terminal.layer.dockBottom': 'Bottom',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
|
||||
'terminal.layer.layoutSaveFailed': 'Could not save layout',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
|
||||
@@ -312,8 +312,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
@@ -359,9 +362,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
@@ -541,7 +541,7 @@ Highlight the focused split pane:
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Bright',
|
||||
'settings.appearance.appIcon.dark': 'Dark',
|
||||
@@ -617,6 +617,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.ligatures': 'Font ligatures',
|
||||
'settings.terminal.font.ligatures.desc': 'Render ligatures such as != when the terminal font includes them',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -624,6 +626,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.cursor.style.block': 'Block',
|
||||
'settings.terminal.cursor.style.bar': 'Bar',
|
||||
'settings.terminal.cursor.style.underline': 'Underline',
|
||||
'settings.terminal.cursor.barWidth': 'Bar cursor width',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Width of bar cursors used by applications such as vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Cursor blink',
|
||||
'settings.terminal.cursor.highlightLine': 'Highlight current line',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -708,7 +712,10 @@ Highlight the focused split pane:
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
@@ -1116,6 +1123,7 @@ 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',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const enScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
'scripts.actions.runOnAllTabs': 'Run on all tabs',
|
||||
'scripts.actions.copyCommand': 'Copy command',
|
||||
'scripts.actions.copyCommand.copied': 'Command copied',
|
||||
'scripts.actions.copyCommand.failed': 'Could not copy command',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
@@ -134,6 +135,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.cut': 'Cut',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
@@ -188,6 +190,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
@@ -196,6 +199,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.connected': 'Connected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
@@ -865,6 +869,13 @@ 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',
|
||||
|
||||
@@ -407,6 +407,7 @@ 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',
|
||||
@@ -417,7 +418,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
@@ -715,6 +715,16 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'vault.panel.resizeWidth': 'Resize panel width',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Test Connection',
|
||||
'hostConnectionTest.test': 'Test',
|
||||
'hostConnectionTest.chainOf': 'Chain {current} of {total}',
|
||||
'hostConnectionTest.success': 'Connection successful',
|
||||
'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.',
|
||||
'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s',
|
||||
'hostConnectionTest.failed': 'Connection failed.',
|
||||
'hostConnectionTest.retry': 'Retry',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.',
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
@@ -1008,6 +1018,21 @@ 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',
|
||||
|
||||
@@ -4,6 +4,8 @@ export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.modelCatalogRetry': 'Reintentar',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
@@ -278,6 +280,18 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': 'CLI de MiMo Code de Xiaomi, un fork de OpenCode. Instálalo y configura los proveedores en su propia configuración, luego selecciónalo como agente de codificación externo.',
|
||||
'ai.mimo.detecting': 'Detectando...',
|
||||
'ai.mimo.detected': 'Detectado',
|
||||
'ai.mimo.notFound': 'No encontrado',
|
||||
'ai.mimo.path': 'Ruta:',
|
||||
'ai.mimo.notFoundHint': 'No se pudo encontrar mimo en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.mimo.customPathPlaceholder': 'p. ej. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Verificar',
|
||||
'ai.mimo.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
@@ -314,6 +328,13 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Siempre',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
|
||||
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
|
||||
'ai.externalMcp.allowSession': 'Esta conexión',
|
||||
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
|
||||
'ai.externalMcp.allowHost': 'Este servidor',
|
||||
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
@@ -492,12 +513,18 @@ 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.',
|
||||
@@ -591,6 +618,11 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
|
||||
'terminal.layer.choosePanelPosition': 'Cambiar posición',
|
||||
'terminal.layer.dockLeft': 'Izquierda',
|
||||
'terminal.layer.dockRight': 'Derecha',
|
||||
'terminal.layer.dockBottom': 'Abajo',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
|
||||
@@ -227,8 +227,11 @@ export const esCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
|
||||
'settings.globalHotkey.notSet': 'No configurada',
|
||||
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
|
||||
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
|
||||
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
|
||||
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
|
||||
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
|
||||
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
|
||||
@@ -274,9 +277,6 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
@@ -456,7 +456,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Brillante',
|
||||
'settings.appearance.appIcon.dark': 'Oscuro',
|
||||
@@ -532,6 +532,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.font.weightBold.desc': 'Peso del texto en negrita (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Suavizado de fuente',
|
||||
'settings.terminal.font.smoothing.desc': 'Usar el anti-aliasing nativo de fuentes de macOS',
|
||||
'settings.terminal.font.ligatures': 'Ligaduras de fuente',
|
||||
'settings.terminal.font.ligatures.desc': 'Mostrar ligaduras como != cuando la fuente del terminal las incluye',
|
||||
'settings.terminal.font.linePadding': 'Espaciado de líneas',
|
||||
'settings.terminal.font.linePadding.desc': 'Espacio adicional entre líneas (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Tipo de emulación de terminal',
|
||||
@@ -539,6 +541,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.cursor.style.block': 'Bloque',
|
||||
'settings.terminal.cursor.style.bar': 'Barra',
|
||||
'settings.terminal.cursor.style.underline': 'Subrayado',
|
||||
'settings.terminal.cursor.barWidth': 'Ancho del cursor de barra',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ancho de los cursores de barra utilizados por aplicaciones como vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Parpadeo del cursor',
|
||||
'settings.terminal.cursor.highlightLine': 'Resaltar línea actual',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -623,7 +627,10 @@ Resalta el panel dividido enfocado:
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` borra el historial de desplazamiento',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Haz que `clear` también borre el buffer del historial de desplazamiento (predeterminado de POSIX). Desactívalo para mantener visible el historial después de `clear`.',
|
||||
@@ -1074,6 +1081,7 @@ 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',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const esScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.copyCommand': 'Copiar comando',
|
||||
'scripts.actions.copyCommand.copied': 'Comando copiado',
|
||||
'scripts.actions.copyCommand.failed': 'No se pudo copiar el comando',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
@@ -134,6 +135,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.cut': 'Cortar',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
@@ -188,6 +190,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
@@ -865,6 +868,13 @@ 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',
|
||||
|
||||
@@ -407,6 +407,7 @@ 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',
|
||||
@@ -1007,6 +1008,21 @@ 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',
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.modelCatalogRetry': 'Повторить',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
@@ -282,6 +284,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Всегда',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
|
||||
'ai.externalMcp.allowAll': 'Разрешить все действия',
|
||||
'ai.externalMcp.allowSession': 'Это подключение',
|
||||
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
|
||||
'ai.externalMcp.allowHost': 'Этот сервер',
|
||||
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
@@ -460,12 +469,18 @@ 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 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -559,6 +574,11 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
|
||||
'terminal.layer.choosePanelPosition': 'Изменить положение',
|
||||
'terminal.layer.dockLeft': 'Слева',
|
||||
'terminal.layer.dockRight': 'Справа',
|
||||
'terminal.layer.dockBottom': 'Снизу',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
|
||||
@@ -296,8 +296,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
@@ -343,9 +346,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
@@ -510,7 +510,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
'settings.appearance.appIcon.bright': 'Яркий',
|
||||
'settings.appearance.appIcon.dark': 'Тёмный',
|
||||
@@ -585,6 +585,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
|
||||
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
|
||||
'settings.terminal.font.ligatures': 'Лигатуры шрифта',
|
||||
'settings.terminal.font.ligatures.desc': 'Показывать лигатуры вроде !=, если шрифт терминала их содержит',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
@@ -592,6 +594,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': 'Блок',
|
||||
'settings.terminal.cursor.style.bar': 'Полоса',
|
||||
'settings.terminal.cursor.style.underline': 'Подчёркивание',
|
||||
'settings.terminal.cursor.barWidth': 'Толщина курсора-полосы',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ширина курсоров-полос, используемых приложениями вроде vi (1-4 пикс.)',
|
||||
'settings.terminal.cursor.blink': 'Мигание курсора',
|
||||
'settings.terminal.cursor.highlightLine': 'Подсветка текущей строки',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -659,7 +663,10 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const ruScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
|
||||
'scripts.actions.copyCommand': 'Копировать команду',
|
||||
'scripts.actions.copyCommand.copied': 'Команда скопирована',
|
||||
'scripts.actions.copyCommand.failed': 'Не удалось скопировать команду',
|
||||
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
|
||||
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
|
||||
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
@@ -155,6 +156,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.cut': 'Вырезать',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
@@ -209,6 +211,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
@@ -217,6 +220,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.connected': 'Подключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
@@ -872,6 +876,13 @@ 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': 'Отбросить',
|
||||
|
||||
@@ -102,6 +102,7 @@ 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': 'Экспорт',
|
||||
@@ -445,6 +446,7 @@ 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': 'Скрыть фоновые',
|
||||
@@ -752,6 +754,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'vault.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostConnectionTest.title': 'Проверка подключения',
|
||||
'hostConnectionTest.test': 'Проверить',
|
||||
'hostConnectionTest.chainOf': 'Цепочка {current} из {total}',
|
||||
'hostConnectionTest.success': 'Подключение успешно',
|
||||
'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.',
|
||||
'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с',
|
||||
'hostConnectionTest.failed': 'Не удалось подключиться.',
|
||||
'hostConnectionTest.retry': 'Повторить',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.',
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
@@ -1041,6 +1052,21 @@ 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': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
@@ -83,6 +85,21 @@ test("localized settings include OSC desktop notification labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include bar cursor width labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.cursor.barWidth",
|
||||
"settings.terminal.cursor.barWidth.desc",
|
||||
];
|
||||
|
||||
for (const locale of [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
]) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing bar cursor width labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,8 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重试',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
@@ -287,6 +289,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安装后在它自己的配置里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.mimo.detecting': '检测中...',
|
||||
'ai.mimo.detected': '已检测到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路径:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。请安装或在下方指定可执行文件路径。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '检查',
|
||||
'ai.mimo.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
@@ -323,6 +337,13 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始终',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
|
||||
'ai.externalMcp.allowAll': '允许所有操作',
|
||||
'ai.externalMcp.allowSession': '此次连接',
|
||||
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此服务器',
|
||||
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -501,12 +522,18 @@ 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 分钟)。你可以重新发送消息来重试。',
|
||||
@@ -600,8 +627,16 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '改变位置',
|
||||
'terminal.layer.dockLeft': '左侧',
|
||||
'terminal.layer.dockRight': '右侧',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
|
||||
'terminal.layer.layoutSaveFailed': '布局保存失败',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
|
||||
@@ -316,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
|
||||
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
|
||||
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
@@ -363,9 +366,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
@@ -545,7 +545,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -740,6 +740,7 @@ 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': '导出',
|
||||
@@ -1051,6 +1052,7 @@ 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': '隐藏后台任务',
|
||||
@@ -1061,7 +1063,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.refreshAfterCommand': '终端命令完成后刷新',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
'scripts.actions.runOnAllTabs': '在所有标签页运行',
|
||||
'scripts.actions.copyCommand': '复制命令',
|
||||
'scripts.actions.copyCommand.copied': '命令已复制',
|
||||
'scripts.actions.copyCommand.failed': '复制命令失败',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 个标签处于密码/敏感输入,已跳过',
|
||||
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
|
||||
@@ -278,6 +278,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字体平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
|
||||
'settings.terminal.font.ligatures': '字体连字',
|
||||
'settings.terminal.font.ligatures.desc': '支持连字的字体把 != 这类字符显示成一个符号',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
@@ -285,6 +287,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '块',
|
||||
'settings.terminal.cursor.style.bar': '竖线',
|
||||
'settings.terminal.cursor.style.underline': '下划线',
|
||||
'settings.terminal.cursor.barWidth': '竖线光标粗细',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等应用使用竖线光标时的宽度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.cursor.highlightLine': '高亮当前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '为光标所在行加上淡色背景,方便定位当前输入位置',
|
||||
@@ -344,7 +348,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
@@ -961,6 +968,13 @@ 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': '不保存',
|
||||
|
||||
@@ -130,6 +130,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'vault.panel.resizeWidth': '调整面板宽度',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostConnectionTest.title': '测试连接',
|
||||
'hostConnectionTest.test': '测试',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '连接成功',
|
||||
'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。',
|
||||
'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时',
|
||||
'hostConnectionTest.failed': '连接失败。',
|
||||
'hostConnectionTest.retry': '重试',
|
||||
'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -381,6 +390,21 @@ 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': '未安装连接提供程序',
|
||||
@@ -466,6 +490,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
@@ -537,6 +562,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.cut': '剪切',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
@@ -579,6 +605,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
@@ -588,6 +615,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.waitingForUserInput': '等待用户输入',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.connected': '已连接',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
|
||||
@@ -4,6 +4,8 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重試',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
@@ -278,6 +280,18 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安裝後在它自己的設定裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.mimo.detecting': '偵測中...',
|
||||
'ai.mimo.detected': '已偵測到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路徑:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '檢查',
|
||||
'ai.mimo.resetPath': '重設',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
|
||||
@@ -313,6 +327,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始終',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
|
||||
'ai.externalMcp.allowAll': '允許所有操作',
|
||||
'ai.externalMcp.allowSession': '此次連線',
|
||||
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此伺服器',
|
||||
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -491,12 +512,18 @@ 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 分鐘)。你可以重新發送訊息來重試。',
|
||||
@@ -590,8 +617,16 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '變更位置',
|
||||
'terminal.layer.dockLeft': '左側',
|
||||
'terminal.layer.dockRight': '右側',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
|
||||
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
|
||||
@@ -318,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
|
||||
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
|
||||
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
@@ -365,9 +368,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
@@ -547,7 +547,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -742,6 +742,7 @@ 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': '匯出',
|
||||
@@ -1053,6 +1054,7 @@ 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': '隱藏背景工作',
|
||||
@@ -1063,7 +1065,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有分頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有分頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有分頁執行',
|
||||
'scripts.actions.copyCommand': '複製指令',
|
||||
'scripts.actions.copyCommand.copied': '指令已複製',
|
||||
'scripts.actions.copyCommand.failed': '複製指令失敗',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
|
||||
@@ -278,6 +278,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字型平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
|
||||
'settings.terminal.font.ligatures': '字型連字',
|
||||
'settings.terminal.font.ligatures.desc': '支援連字的字型把 != 這類字元顯示成一個符號',
|
||||
'settings.terminal.font.linePadding': '行間距',
|
||||
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '終端模擬型別',
|
||||
@@ -285,6 +287,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '塊',
|
||||
'settings.terminal.cursor.style.bar': '豎線',
|
||||
'settings.terminal.cursor.style.underline': '下劃線',
|
||||
'settings.terminal.cursor.barWidth': '豎線游標粗細',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等應用程式使用豎線游標時的寬度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '游標閃爍',
|
||||
'settings.terminal.cursor.highlightLine': '高亮目前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '為游標所在行加上淡色背景,方便定位目前輸入位置',
|
||||
@@ -344,7 +348,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
@@ -961,6 +968,13 @@ 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': '不儲存',
|
||||
|
||||
@@ -129,6 +129,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新增主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostConnectionTest.title': '測試連線',
|
||||
'hostConnectionTest.test': '測試',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '連線成功',
|
||||
'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。',
|
||||
'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時',
|
||||
'hostConnectionTest.failed': '連線失敗。',
|
||||
'hostConnectionTest.retry': '重試',
|
||||
'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。',
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -381,6 +390,21 @@ 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': '尚未安裝連線提供者',
|
||||
@@ -466,6 +490,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
@@ -537,6 +562,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.cut': '剪下',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
@@ -579,6 +605,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
@@ -588,6 +615,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
|
||||
'terminal.progress.waitingForUserInput': '等待使用者輸入',
|
||||
'terminal.progress.disconnected': '已斷開',
|
||||
'terminal.progress.connected': '已連線',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新開始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
|
||||
|
||||
@@ -466,6 +466,115 @@ 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[] = [];
|
||||
|
||||
@@ -195,12 +195,40 @@ 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. 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.
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
@@ -218,7 +246,7 @@ function stripReasoningEncryptedContent(
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
@@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
|
||||
};
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
@@ -310,6 +354,11 @@ 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[],
|
||||
@@ -321,13 +370,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 ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
@@ -339,7 +388,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,11 +413,12 @@ export function serializeSessionsForStorage(
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
@@ -378,27 +428,27 @@ export function serializeSessionsForStorage(
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
});
|
||||
@@ -28,6 +28,20 @@ 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());
|
||||
|
||||
@@ -2,6 +2,7 @@ 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).
|
||||
@@ -23,6 +24,7 @@ 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;
|
||||
@@ -32,12 +34,16 @@ export interface EditorTab {
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
placement?: EditorTabPlacement;
|
||||
windowDirty?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
|
||||
|
||||
export const tabIsDirty = (tab: EditorTab): boolean =>
|
||||
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
@@ -79,7 +85,82 @@ export class EditorTabStore {
|
||||
};
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
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;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
@@ -91,6 +172,12 @@ 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 });
|
||||
};
|
||||
|
||||
@@ -174,17 +261,26 @@ 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;
|
||||
@@ -204,6 +300,8 @@ export class EditorTabStore {
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement: snapshot.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
@@ -221,9 +319,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));
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
@@ -332,9 +430,11 @@ export type EditorTabChrome = Pick<
|
||||
| 'sessionId'
|
||||
| 'sftpTabId'
|
||||
| 'hostId'
|
||||
| 'hostLabel'
|
||||
| 'remotePath'
|
||||
| 'fileName'
|
||||
| 'languageId'
|
||||
| 'placement'
|
||||
>;
|
||||
|
||||
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
@@ -343,9 +443,11 @@ 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 = (
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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[];
|
||||
}
|
||||
@@ -2,15 +2,26 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatSessionClosedExitMessage,
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
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.
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -56,11 +67,19 @@ 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", () => {
|
||||
@@ -120,3 +139,65 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages show the reported code for a real remote exit", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
|
||||
"\r\n[session closed (code 0)]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
|
||||
"\r\n[session closed (code 127)]",
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
reason: "closed",
|
||||
remoteClosedWithoutExitStatus: true,
|
||||
}),
|
||||
"\r\n[session closed by remote (no exit status)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed" }),
|
||||
"\r\n[session closed]",
|
||||
);
|
||||
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,31 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
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;
|
||||
// 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".
|
||||
remoteClosedWithoutExitStatus?: boolean;
|
||||
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
|
||||
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})` : ""}]`;
|
||||
}
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
@@ -23,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
autoCloseOnExit = true,
|
||||
// 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,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runAutomationScript,
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
function installPermissionMode(mode: string) {
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const writeScript: Snippet = {
|
||||
id: 'write-script',
|
||||
label: 'Write',
|
||||
command: "await nct.screen.sendLine('echo hi');",
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'user-run', runIds: ['user-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
const result = await runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-user',
|
||||
initiatedBy: 'user',
|
||||
});
|
||||
assert.equal(result.runId, 'user-run');
|
||||
assert.equal(seenMode, 'auto');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'automatic-run', runIds: ['automatic-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks an AI write script in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'ai-run', runIds: ['ai-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-ai',
|
||||
initiatedBy: 'ai',
|
||||
}),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'ai-read', runIds: ['ai-read'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet: {
|
||||
id: 'read-script',
|
||||
label: 'Read',
|
||||
command: "await nct.screen.waitForPrompt(1000);",
|
||||
kind: 'script',
|
||||
},
|
||||
sessionId: 'sess-ai-read',
|
||||
initiatedBy: 'ai',
|
||||
});
|
||||
assert.equal(seenMode, 'observer');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
|
||||
initiatedBy?: 'user' | 'ai';
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -18,6 +19,28 @@ 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,
|
||||
@@ -113,6 +136,30 @@ 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 } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
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,3 +209,13 @@ 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);
|
||||
});
|
||||
@@ -23,7 +23,6 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
@@ -58,7 +57,6 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
@@ -86,7 +84,6 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||
@@ -104,7 +102,6 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -153,7 +150,6 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -310,9 +306,6 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -379,7 +372,6 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
function installMemoryLocalStorage(): Map<string, string> {
|
||||
@@ -140,3 +145,72 @@ 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,5 +1,9 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
TERMINAL_FONT_AUTO,
|
||||
@@ -63,6 +67,30 @@ 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,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -94,7 +92,6 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
@@ -146,7 +143,6 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -186,7 +182,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -195,7 +191,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -210,7 +206,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -220,7 +216,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -441,9 +437,6 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
@@ -601,7 +594,7 @@ export function useSettingsStorageSync({
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
|
||||
|
||||
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
|
||||
name: "f",
|
||||
size: 50_000,
|
||||
type: "file",
|
||||
lastModified: 0,
|
||||
sizeKnown: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const listedSize of [25_000, 100_000]) {
|
||||
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async (_id, path, encoding) => {
|
||||
assert.equal(path, "/tmp/f");
|
||||
assert.equal(encoding, "auto");
|
||||
return stat();
|
||||
},
|
||||
"sftp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
|
||||
assert.notEqual(snapshot.size, listedSize);
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale file entry replaced by a directory uses directory routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, type: "directory" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
|
||||
test("a stale directory entry replaced by a file uses file routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 12, type: "file" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
|
||||
});
|
||||
|
||||
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
|
||||
const rejected = [
|
||||
undefined,
|
||||
async () => null as unknown as SftpStatResult,
|
||||
async () => { throw new Error("File not found"); },
|
||||
async () => stat({ type: "symlink" }),
|
||||
async () => stat({ size: Number.NaN }),
|
||||
async () => stat({ size: -1 }),
|
||||
];
|
||||
for (const candidate of rejected) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, sizeKnown: false }),
|
||||
"scp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
|
||||
});
|
||||
|
||||
test("SCP links retain their resolved file or directory routing without using link size", async () => {
|
||||
for (const target of ["file", "directory"] as const) {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink", size: 8 }),
|
||||
"scp-1", "/tmp/link", "auto",
|
||||
async (_id, parent, encoding) => {
|
||||
assert.equal(parent, "/tmp");
|
||||
assert.equal(encoding, "auto");
|
||||
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
|
||||
},
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
|
||||
}
|
||||
});
|
||||
|
||||
test("SCP link whose current target cannot be confirmed stops safely", async () => {
|
||||
for (const entries of [
|
||||
[],
|
||||
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
|
||||
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink" }),
|
||||
"scp-1", "/tmp/link", "auto", async () => entries,
|
||||
),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("directory routing does not require a file byte count", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
|
||||
"sftp-1", "/tmp/d", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
|
||||
import type { RemoteFile } from "../../../domain/models/workspace";
|
||||
import { getFileName, getParentPath } from "./utils";
|
||||
|
||||
export type DownloadSourceSnapshot = {
|
||||
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
|
||||
size: number | undefined;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type StatSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<SftpStatResult>;
|
||||
type ListSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<RemoteFile[]>;
|
||||
|
||||
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
|
||||
|
||||
/** A stale listing must never supply the size or type of a new download. */
|
||||
export const resolveDownloadSourceSnapshot = async (
|
||||
statSftp: StatSftp | undefined,
|
||||
sftpId: string,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
listSftp?: ListSftp,
|
||||
): Promise<DownloadSourceSnapshot> => {
|
||||
if (!statSftp) throw sourceUnavailable();
|
||||
|
||||
let stat: SftpStatResult | null;
|
||||
try {
|
||||
stat = await statSftp(sftpId, sourcePath, encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
|
||||
if (!stat) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
if (stat.type === "directory") return { size: 0, isDirectory: true };
|
||||
if (stat.type === "symlink") {
|
||||
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
|
||||
// parent to resolve the current target kind rather than trusting the pane.
|
||||
if (!listSftp) throw sourceUnavailable();
|
||||
let entries: RemoteFile[];
|
||||
try {
|
||||
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
|
||||
if (current?.type !== "symlink"
|
||||
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
// The link's own size is never a valid file plan; SCP's wire header is.
|
||||
return { size: undefined, isDirectory: current.linkTarget === "directory" };
|
||||
}
|
||||
if (stat.type !== "file") throw sourceUnavailable();
|
||||
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
|
||||
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
|
||||
return { size: stat.size, isDirectory: false };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
@@ -64,3 +64,15 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("unwrapSftpIpcError strips the Electron IPC wrapper", () => {
|
||||
assert.equal(
|
||||
unwrapSftpIpcError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:delete': Error: Permission denied"),
|
||||
),
|
||||
"Permission denied",
|
||||
);
|
||||
assert.equal(unwrapSftpIpcError(new Error("Permission denied")), "Permission denied");
|
||||
assert.equal(unwrapSftpIpcError("channel closed"), "channel closed");
|
||||
});
|
||||
@@ -18,4 +18,13 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const SFTP_IPC_ERROR_PREFIX = /^Error invoking remote method 'netcatty:[^']+':\s*/i;
|
||||
|
||||
/** Drop Electron IPC wrapper so dialogs can show the server error. */
|
||||
export function unwrapSftpIpcError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? "");
|
||||
const unwrapped = raw.replace(SFTP_IPC_ERROR_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
||||
return unwrapped || raw;
|
||||
}
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { UploadController } from "../../../lib/uploadService";
|
||||
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -37,3 +37,64 @@ 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,11 +5,13 @@ 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);
|
||||
@@ -19,6 +21,7 @@ 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);
|
||||
@@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController)
|
||||
for (const taskId of taskIds) {
|
||||
if (controllersByTaskId.get(taskId) === controller) {
|
||||
controllersByTaskId.delete(taskId);
|
||||
scopesByTaskId.delete(taskId);
|
||||
}
|
||||
}
|
||||
taskIdsByController.delete(controller);
|
||||
@@ -40,6 +44,10 @@ 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()];
|
||||
@@ -50,5 +58,6 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
|
||||
|
||||
export function resetExternalUploadRuntimeForTests(): void {
|
||||
controllersByTaskId.clear();
|
||||
scopesByTaskId.clear();
|
||||
taskIdsByController.clear();
|
||||
}
|
||||
@@ -520,3 +520,24 @@ 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,6 +24,7 @@ import {
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
import { isTransferOrRootCancelled } from "./transferCancelLatch";
|
||||
import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
|
||||
export type TransferControlBridge = {
|
||||
@@ -72,7 +73,7 @@ export function reconcileSupersededControls(
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) 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") => {
|
||||
@@ -234,7 +235,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)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
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,13 +972,10 @@ 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((t) =>
|
||||
t.id === fileId
|
||||
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
setTransfers((prev) => prev.map((task) =>
|
||||
task.id === fileId
|
||||
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: task));
|
||||
errors.push(err instanceof Error ? err : new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ 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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,21 +71,24 @@ export function useSftpTransferTaskOps({
|
||||
const bridge = netcattyBridge.get();
|
||||
const cancelTransferAtBackend = bridge?.cancelTransfer;
|
||||
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
|
||||
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
|
||||
if (!cancelTransferAtBackend && !cancelCompressedUpload) return [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsToCancel).map(async (id) => {
|
||||
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) => {
|
||||
const candidate = currentTransfers.find((task) => task.id === id);
|
||||
const compressed = candidate?.controlKind === "compressed-upload";
|
||||
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");
|
||||
try {
|
||||
const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id));
|
||||
if (result && result.success === false) failed.push(id);
|
||||
} catch {
|
||||
failed.push(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}));
|
||||
}
|
||||
if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends");
|
||||
return failed;
|
||||
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
|
||||
|
||||
const markBatchStopped = useCallback(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
const inFlightRootIds = new Set<string>();
|
||||
const settlementWaiters = new Map<string, Set<() => void>>();
|
||||
|
||||
export function registerTransferWalk(rootTaskId: string): void {
|
||||
inFlightRootIds.add(rootTaskId);
|
||||
@@ -13,15 +14,28 @@ 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 {
|
||||
inFlightRootIds.clear();
|
||||
for (const id of inFlightRootIds) unregisterTransferWalk(id);
|
||||
}
|
||||
|
||||
export function listTransferWalksForTests(): string[] {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let count = 0;
|
||||
let options = {
|
||||
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
|
||||
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
|
||||
path: '/browsed', connected: true, refresh: async () => { count += 1; },
|
||||
};
|
||||
function Probe() { useSftpCommandRefresh(options); return null; }
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const update = async (patch: Partial<typeof options>) => {
|
||||
options = { ...options, ...patch };
|
||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
||||
};
|
||||
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
|
||||
publishTerminalCommandCompletion('another-terminal');
|
||||
await finish();
|
||||
assert.equal(count, 0);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
|
||||
await update(patch);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
|
||||
}
|
||||
await update({ busy: true });
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ busy: false });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await update(patch);
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
}
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
|
||||
interface Options {
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
visible: boolean;
|
||||
busy: boolean;
|
||||
matchesTerminal: boolean;
|
||||
paneId: string;
|
||||
connectionId: string | null;
|
||||
path: string | null;
|
||||
connected: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
|
||||
export function useSftpCommandRefresh(options: Options): void {
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const drain = useRef(() => {});
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !options.visible || !options.sessionId) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
let refreshing = false;
|
||||
let disposed = false;
|
||||
const eligible = () => {
|
||||
const live = latest.current;
|
||||
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
|
||||
&& live.sessionId === options.sessionId && live.paneId === options.paneId
|
||||
&& live.connectionId === options.connectionId && live.path === options.path;
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!pending || refreshing || latest.current.busy || !eligible()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (!eligible() || latest.current.busy) return;
|
||||
pending = false;
|
||||
refreshing = true;
|
||||
void latest.current.refresh().catch(() => {}).finally(() => {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
drain.current = schedule;
|
||||
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
|
||||
if (sessionId !== options.sessionId || !eligible()) return;
|
||||
pending = true;
|
||||
schedule();
|
||||
});
|
||||
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
|
||||
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
|
||||
useEffect(() => { drain.current(); }, [options.busy]);
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export interface SftpConnectOptions {
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** When false, never attach SFTP to a live terminal SSH transport. */
|
||||
reuseTransport?: boolean;
|
||||
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
|
||||
connectRequestKey?: string;
|
||||
/** Identifies the exact tab and connection created for a route-bound request. */
|
||||
@@ -1074,6 +1076,7 @@ export const useSftpConnections = ({
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
...(options?.reuseTransport === false ? { reuseTransport: false } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ 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";
|
||||
@@ -200,8 +201,8 @@ export const useSftpExternalOperations = (
|
||||
return { sftpId, release: () => {} };
|
||||
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
|
||||
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
|
||||
registerExternalUploadController(taskId, controller);
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
|
||||
registerExternalUploadController(taskId, controller, scope);
|
||||
}, []);
|
||||
|
||||
const unregisterUploadController = useCallback((controller: UploadController) => {
|
||||
@@ -221,7 +222,7 @@ export const useSftpExternalOperations = (
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller);
|
||||
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
|
||||
if (task.parentTaskId) {
|
||||
registerUploadController(task.parentTaskId, controller);
|
||||
}
|
||||
@@ -443,6 +444,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
if (liveConnectionId !== connectionId) {
|
||||
editorTabStore.remapSessionId(connectionId, liveConnectionId);
|
||||
notifyEditorWindowSessionRemap(connectionId, liveConnectionId);
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -85,6 +85,60 @@ test("first-open follow uses a fresh terminal cwd instead of the cached home pat
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow stays put when the terminal cwd is still an unresolved ~", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/root",
|
||||
homeDir: "/root",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "~",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async () => "~",
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
assert.equal(connection.currentPath, "/root");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
resolveTerminalCwdForSftp,
|
||||
isSftpFollowTargetPath,
|
||||
fallbackFollowPathForUntrackedSession,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
@@ -23,6 +26,7 @@ import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActio
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -48,6 +52,7 @@ type GetTerminalCwd = (options?: {
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -85,13 +90,21 @@ const runInitialFollowTerminalCwdSync = async ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetPath = resolveTerminalCwdForSftp(cwd, live.homeDir, live.currentPath);
|
||||
if (!isSftpFollowTargetPath(targetPath)) {
|
||||
// Prompt-only shortcuts such as `~` are not a real SFTP path yet. Stay on
|
||||
// the connection's landing directory instead of listing `/~`.
|
||||
setHandled({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== targetPath ? staleTerminalCwd : targetPath,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
if (live.currentPath === targetPath) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
const navigateResult = await navigate(targetPath, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
@@ -174,6 +187,7 @@ export function useSftpFollowTerminalCwd({
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
const lastFollowOriginIdRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
@@ -257,10 +271,16 @@ export function useSftpFollowTerminalCwd({
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
cwd,
|
||||
connection?.homeDir,
|
||||
connection?.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, { shouldApply });
|
||||
if (navigateResult !== "reached" || !shouldApply()) return;
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
@@ -275,9 +295,23 @@ export function useSftpFollowTerminalCwd({
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const previousOriginId = lastFollowOriginIdRef.current;
|
||||
const originChanged = Boolean(
|
||||
previousOriginId
|
||||
&& expectedSessionId
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
if (!terminalCwd && originChanged) {
|
||||
// Do not reuse an in-flight probe from the pane we just left.
|
||||
terminalCwd = fallbackFollowPathForUntrackedSession({
|
||||
originChanged,
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
@@ -335,26 +369,36 @@ export function useSftpFollowTerminalCwd({
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
if (!connection) return;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
terminalCwd,
|
||||
connection.homeDir,
|
||||
connection.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
quiet: true,
|
||||
});
|
||||
if (!shouldApplyCurrentFollowSync()) return;
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) return;
|
||||
if (navigateResult === "failed") {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
@@ -380,6 +424,7 @@ export function useSftpFollowTerminalCwd({
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
@@ -387,6 +432,7 @@ export function useSftpFollowTerminalCwd({
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
@@ -533,7 +579,7 @@ export function useSftpFollowTerminalCwd({
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply, quiet: true });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSftpHostCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
|
||||
const identities = [
|
||||
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
|
||||
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
|
||||
] as Identity[];
|
||||
const savedHost = host({ identityId: "root-id" });
|
||||
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
|
||||
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
|
||||
assert.equal(credentials.username, "deploy");
|
||||
assert.equal(credentials.password, "good-deploy");
|
||||
assert.equal(credentials.hostId, savedHost.id);
|
||||
assert.equal(savedHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSftpPaneActions } from './useSftpPaneActions';
|
||||
import { createEmptyPane, type SftpPane } from './types';
|
||||
import type { SftpFileEntry } from '../../../types';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
|
||||
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
|
||||
let result = [file('kept'), file('new')];
|
||||
const selection = new Set(['kept']);
|
||||
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
|
||||
const left = { current: { tabs: [pane], activeTabId: pane.id } };
|
||||
const calls: string[] = [];
|
||||
let actions: ReturnType<typeof useSftpPaneActions>;
|
||||
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
|
||||
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
|
||||
};
|
||||
function Probe() {
|
||||
actions = useSftpPaneActions({
|
||||
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
|
||||
updateActiveTab: (side, updater) => update(side, pane.id, updater),
|
||||
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
|
||||
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
|
||||
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
|
||||
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
|
||||
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
|
||||
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
|
||||
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
|
||||
assert.deepEqual(calls, ['/browsed']);
|
||||
assert.equal(left.current.tabs[0].selectedFiles, selection);
|
||||
assert.equal(left.current.tabs[0].filter, 'ke');
|
||||
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
|
||||
result = [file('new')];
|
||||
await actions!.refresh('left', { preserveSelection: true });
|
||||
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
|
||||
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { SftpPane } from "./types";
|
||||
import {
|
||||
getFileName,
|
||||
@@ -27,13 +28,6 @@ import {
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
/** Retain selection identity when no selected entry disappeared. */
|
||||
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
|
||||
const names = new Set(files.map((file) => file.name));
|
||||
const retained = new Set([...selected].filter((name) => names.has(name)));
|
||||
return retained.size === selected.size ? selected : retained;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -60,14 +54,15 @@ interface UseSftpPaneActionsParams {
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
preserveSelection?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
/** Restore the previous listing without showing a pane error. */
|
||||
quiet?: boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -187,7 +182,6 @@ export const useSftpPaneActions = ({
|
||||
pane.connection.homeDir,
|
||||
);
|
||||
|
||||
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
|
||||
@@ -290,7 +284,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: normalizedPath }
|
||||
: null,
|
||||
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -378,7 +372,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path: normalizedPath,
|
||||
files,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
@@ -389,7 +383,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
@@ -425,8 +419,9 @@ export const useSftpPaneActions = ({
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: options?.quiet
|
||||
? previousError
|
||||
: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
@@ -454,7 +449,7 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
@@ -485,7 +480,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
@@ -1039,6 +1034,30 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const removeListedNames = useCallback((
|
||||
side: "left" | "right",
|
||||
parentPath: string,
|
||||
names: string[],
|
||||
) => {
|
||||
if (names.length === 0) return;
|
||||
const removeSet = new Set(names);
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.currentPath !== parentPath) return prev;
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of names) nextSelection.delete(name);
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
}, [clearCacheForConnection, getActivePane, updateActiveTab]);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
@@ -1056,6 +1075,7 @@ export const useSftpPaneActions = ({
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/** @test issue #3559 — dual-pane transfers must re-stat remote sources before planning */
|
||||
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 { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
totalBytes?: number;
|
||||
sourceSftpId?: string;
|
||||
};
|
||||
|
||||
/** Remote pane whose listing was captured before the file grew to 3626 bytes. */
|
||||
function makePane(side: "left" | "right"): SftpPane {
|
||||
const isLocal = side === "right";
|
||||
return {
|
||||
id: side,
|
||||
connection: {
|
||||
id: isLocal ? "local-conn" : "remote-conn",
|
||||
hostId: isLocal ? undefined : "host-1",
|
||||
hostLabel: isLocal ? "Local" : "SSH host",
|
||||
isLocal,
|
||||
status: "connected",
|
||||
currentPath: isLocal ? "/Users/reporter/Downloads" : "/opt",
|
||||
},
|
||||
files: isLocal
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3622,
|
||||
sizeFormatted: "3.5 KB",
|
||||
lastModified: 1758000000000,
|
||||
lastModifiedFormatted: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set<string>(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: true,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function installGlobals(netty: Record<string, unknown>) {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const previousStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousRaf = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame;
|
||||
const previousCancelRaf = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame;
|
||||
// Progress patches are rAF-coalesced; provide a microtask-ish shim.
|
||||
const raf = (cb: (time: number) => void) => setTimeout(() => cb(Date.now()), 0);
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = raf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (id: unknown) => clearTimeout(id as number);
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: netty,
|
||||
requestAnimationFrame: raf,
|
||||
cancelAnimationFrame: (id: unknown) => clearTimeout(id as number),
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
return () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRaf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelRaf;
|
||||
};
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
? "dual-pane download plan uses the live source size, not the stale listing (#3559)"
|
||||
: "dual-pane download plan falls back to live size measurement when stat is unavailable (#3559)",
|
||||
async () => {
|
||||
const statCalls: Array<{ sftpId: string; path: string }> = [];
|
||||
const startedOptions: StartOptions[] = [];
|
||||
let resolveTransfer!: (options: StartOptions) => void;
|
||||
const transferStarted = new Promise<StartOptions>((resolve) => { resolveTransfer = resolve; });
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async (sftpId: string, target: string) => {
|
||||
if (!liveStatAvailable) return undefined;
|
||||
statCalls.push({ sftpId, path: target });
|
||||
return {
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3626,
|
||||
sizeKnown: true,
|
||||
lastModified: 1758000001000,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
startedOptions.push(options);
|
||||
resolveTransfer(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const running = ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const options = await transferStarted;
|
||||
startedOptions.length = 0;
|
||||
startedOptions.push(options);
|
||||
await running;
|
||||
});
|
||||
|
||||
// Every fresh remote plan must come from a live re-stat of the source.
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(statCalls.length, 1);
|
||||
assert.equal(statCalls[0].sftpId, "sftp-remote");
|
||||
assert.equal(statCalls[0].path, "/opt/aops_dns_view_demo.sh");
|
||||
}
|
||||
const planBytes = startedOptions[0]?.totalBytes;
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(planBytes, 3626, "plan must use the live remote size, not the stale listing 3622");
|
||||
} else {
|
||||
// No live stat possible: plan 0 must be omitted so the transfer
|
||||
// bridge measures the live size instead of truncating.
|
||||
assert.equal(planBytes, undefined);
|
||||
}
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("stat-less remote source keeps the listing size in the overwrite dialog and leaves the plan unknown", async () => {
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
}),
|
||||
statLocal: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 100,
|
||||
lastModified: 1,
|
||||
}),
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, 3622);
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000000000);
|
||||
const task = ops!.transfers.find((row) => row.fileName === "aops_dns_view_demo.sh");
|
||||
assert.equal(task?.totalBytes, 0, "listing size must not become the transfer plan");
|
||||
assert.equal(task?.status, "attention");
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote transfer preflight stats stay within the concurrency cap and file bound", async () => {
|
||||
const limit = PREFLIGHT_STAT_CONCURRENCY;
|
||||
const fileCount = limit + 4;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
let releaseAll = () => {};
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
let reachedCap = () => {};
|
||||
const hitCap = new Promise<void>((resolve) => { reachedCap = resolve; });
|
||||
const names = Array.from({ length: fileCount }, (_, index) => `file-${index}.bin`);
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
if (inflight >= limit) reachedCap();
|
||||
try {
|
||||
await gate;
|
||||
} finally {
|
||||
inflight -= 1;
|
||||
}
|
||||
return {
|
||||
name: "file",
|
||||
type: "file" as const,
|
||||
size: 10,
|
||||
sizeKnown: true,
|
||||
lastModified: 1,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-concurrency",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
let running: Promise<unknown> = Promise.resolve();
|
||||
await act(async () => {
|
||||
running = ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const capTimeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("preflight did not reach the concurrency cap")), 2000);
|
||||
});
|
||||
await Promise.race([hitCap, capTimeout]);
|
||||
});
|
||||
assert.equal(maxInflight, limit);
|
||||
assert.ok(inflight <= limit);
|
||||
releaseAll();
|
||||
await act(async () => { await running; });
|
||||
assert.equal(maxInflight, limit);
|
||||
} finally {
|
||||
releaseAll();
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preflight timeout stops the rest of the batch", async () => {
|
||||
const extra = 4;
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + extra },
|
||||
(_, index) => `hung-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: () => {
|
||||
statCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-timeout",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
// One preflight wave, then stop. Timed-out files are not statted again.
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_CONCURRENCY);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less preflight results are not statted again before transfer", async () => {
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + 2 },
|
||||
(_, index) => `legacy-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
await Promise.resolve();
|
||||
inflight -= 1;
|
||||
return {
|
||||
name: "legacy",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless-batch",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, names.length);
|
||||
assert.ok(maxInflight <= PREFLIGHT_STAT_CONCURRENCY);
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(started.filter((options) => options.totalBytes === undefined).length, names.length);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("files past the preflight bound stay un-statted after conflict resolution", async () => {
|
||||
const extra = 2;
|
||||
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
|
||||
let statCalls = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
name: "bulk",
|
||||
type: "file" as const,
|
||||
size: 20,
|
||||
sizeKnown: true,
|
||||
lastModified: 5,
|
||||
};
|
||||
},
|
||||
statLocal: async (target: string) => names.slice(-extra).some((name) => target.endsWith(`/${name}`))
|
||||
? { name: target.split("/").pop() || "", type: "file" as const, size: 1, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-bound",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
|
||||
assert.equal(started.length, names.length - extra);
|
||||
assert.equal(ops!.conflicts.length, extra);
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(ops!.conflicts.length, extra, "conflicts must survive panel remount");
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "replace", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES, "conflict resolution must keep the preflight cap");
|
||||
const omitted = started.filter((options) => options.totalBytes === undefined);
|
||||
assert.equal(omitted.length, extra);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -63,6 +63,21 @@ import {
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a half-open
|
||||
// session can leave a stat pending forever. Every stat issued outside the
|
||||
// bridge is bounded with this timeout; on timeout the caller falls back to
|
||||
// stale metadata / a 0 plan instead of one hung stat blocking a batch.
|
||||
const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Preflight re-stats share the browse SFTP session and the renderer IPC
|
||||
// queue. Launching one stat per file for a huge multi-file selection would
|
||||
// flood both before any transfer task reaches the UI, so cap the number of
|
||||
// in-flight preflight stats and skip the re-stat entirely once the selection
|
||||
// grows past a bound (files beyond the cap stay on the safe 0 plan, which
|
||||
// makes the bridge measure the live size anyway).
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -207,7 +222,6 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
@@ -613,9 +627,30 @@ export const useSftpTransfers = ({
|
||||
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
const discoverTransferSize = async () => {
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
// metadata from the local task copy.
|
||||
const discoverTransferSize = async (): Promise<{
|
||||
size: number;
|
||||
lastModified?: number;
|
||||
/** False for pane-listing display data. That size must not become the plan. */
|
||||
plan: boolean;
|
||||
} | null> => {
|
||||
// When the endpoint cannot stat the source (legacy SCP returns the
|
||||
// sizeKnown: false placeholder) or the stat fails, the transfer plan
|
||||
// stays unknown (0 bytes, so the bridge measures the live size). The
|
||||
// overwrite dialog still needs display metadata, so fall back to the
|
||||
// retained cached listing entry instead of reporting 0 bytes — but only
|
||||
// for the dialog; the plan itself must never take the listing's
|
||||
// possibly stale size back.
|
||||
const cachedListingStatForDialog = (): { size: number; lastModified?: number; plan: false } | null => {
|
||||
if (getParentPath(task.sourcePath) !== sourcePane.connection?.currentPath) return null;
|
||||
const entry = sourcePane.files.find((candidate) => candidate.name === task.fileName);
|
||||
if (!entry || !(entry.size > 0)) return null;
|
||||
return { size: entry.size, lastModified: entry.lastModified, plan: false };
|
||||
};
|
||||
try {
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return null;
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
@@ -628,17 +663,41 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
if (task.preflightStatSkipped) {
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
// This stat runs deferred (started before the conflict check) and
|
||||
// can be awaited by the conflict path, so it must not hang on a
|
||||
// half-open session. Bound it like the preflight stat and settle
|
||||
// early once the task is cancelled, since a marked task cannot
|
||||
// settle an in-flight SFTP callback itself.
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => resolve(undefined), STAT_SFTP_TIMEOUT_MS);
|
||||
}),
|
||||
new Promise<undefined>((resolve) => {
|
||||
cancelTimer = setInterval(() => {
|
||||
if (cancelledTasksRef.current.has(task.id)) resolve(undefined);
|
||||
}, 250);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (cancelTimer) clearInterval(cancelTimer);
|
||||
});
|
||||
if (stat && stat.sizeKnown !== false) {
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
@@ -647,12 +706,18 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
// Unknown size (e.g. legacy SCP stat): keep the transfer plan
|
||||
// unknown so a 0-byte placeholder never replaces trusted metadata,
|
||||
// but give the conflict dialog the retained cached listing entry.
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isTransferCancelledError(err)) {
|
||||
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
|
||||
}
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -746,16 +811,35 @@ export const useSftpTransfers = ({
|
||||
return null;
|
||||
})();
|
||||
|
||||
// For single files: fire-and-forget size discovery
|
||||
if (!task.isDirectory) {
|
||||
void discoverTransferSize();
|
||||
}
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
const conflict = await conflictCheckPromise;
|
||||
// Cancel/Stop may have won while conflict stats were in flight.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current
|
||||
.get(task.batchId ?? "global")
|
||||
@@ -819,7 +903,9 @@ export const useSftpTransfers = ({
|
||||
setConflicts((prev) => [...prev, conflict]);
|
||||
updateTask({
|
||||
status: "attention",
|
||||
totalBytes: conflict.newSize || task.totalBytes || 0,
|
||||
// Dialog newSize may be the listing fallback (plan: false). Only a
|
||||
// verified stat may become the plan; 0 stays omitted at startStreamTransfer.
|
||||
totalBytes: verifiedPlanBytes ?? 0,
|
||||
conflict,
|
||||
});
|
||||
return "attention";
|
||||
@@ -1094,6 +1180,84 @@ export const useSftpTransfers = ({
|
||||
? new Map(sourcePane.files.map(f => [f.name, f]))
|
||||
: null;
|
||||
|
||||
// Pane listings can be stale when the file changed in a terminal or
|
||||
// another session after the pane was listed (#3559). A stale undersized
|
||||
// size becomes the transfer plan and silently truncates the download, so
|
||||
// remote single files are always re-statted before the plan is built.
|
||||
// When no live stat is possible the plan stays 0, which makes the bridge
|
||||
// measure the live size instead of trusting the stale listing.
|
||||
const freshRemoteMetadata = new Map<string, { size: number; lastModified: number }>();
|
||||
const sourceSftpIdForPlan = sourcePane.connection.isLocal
|
||||
? null
|
||||
: (sftpSessionsRef.current.get(sourceConnectionId) ?? null);
|
||||
const remoteSingleFiles = sourcePane.connection.isLocal
|
||||
? []
|
||||
: sourceFiles.filter((file) => !file.isDirectory);
|
||||
const preflightFiles = remoteSingleFiles.slice(0, PREFLIGHT_STAT_MAX_FILES);
|
||||
// Remote files without a verified live stat are not statted again in
|
||||
// discoverTransferSize. That covers the tail past the cap, a missing
|
||||
// browse session, a timeout that stops the pool, and a stat that
|
||||
// returns no usable size. The plan stays unknown so the bridge measures
|
||||
// the live size; the overwrite dialog still uses the pane listing.
|
||||
const preflightSkippedNames = new Set<string>();
|
||||
if (remoteSingleFiles.length > 0 && sourceSftpIdForPlan) {
|
||||
const sourceEncoding = sourcePane.filenameEncoding || "auto";
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a
|
||||
// half-open session can leave a stat pending forever. Bound each
|
||||
// preflight stat: on timeout the file keeps its 0 plan and the bridge
|
||||
// measures the live size during the transfer instead of one hung stat
|
||||
// blocking the entire selected batch.
|
||||
const PREFLIGHT_STAT_TIMEOUT_MS = STAT_SFTP_TIMEOUT_MS;
|
||||
// These stats share the browse session. Keep a few in flight, and
|
||||
// after the first timeout stop claiming more files so a half-open
|
||||
// session cannot run the whole selection out to 10s each.
|
||||
let preflightStopped = false;
|
||||
const statOneRemoteFile = async (file: { name: string; isDirectory: boolean }) => {
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
try {
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpIdForPlan,
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve(undefined);
|
||||
}, PREFLIGHT_STAT_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timedOut) {
|
||||
preflightStopped = true;
|
||||
return;
|
||||
}
|
||||
if (stat && stat.type !== "symlink" && stat.sizeKnown !== false && stat.size >= 0) {
|
||||
freshRemoteMetadata.set(file.name, { size: stat.size, lastModified: stat.lastModified });
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to a 0 plan so the bridge measures the live size.
|
||||
logger.debug?.("[SFTP] Transfer plan re-stat failed; falling back to live size", err);
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
}
|
||||
};
|
||||
const workers = Math.min(PREFLIGHT_STAT_CONCURRENCY, preflightFiles.length);
|
||||
let preflightCursor = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: workers }, async () => {
|
||||
while (!preflightStopped && preflightCursor < preflightFiles.length) {
|
||||
const file = preflightFiles[preflightCursor++];
|
||||
await statOneRemoteFile(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const file of remoteSingleFiles) {
|
||||
if (!freshRemoteMetadata.has(file.name)) preflightSkippedNames.add(file.name);
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
@@ -1102,12 +1266,30 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network, but only when the transfer
|
||||
// source matches the pane's currently listed directory.
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
@@ -1167,8 +1349,9 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const taskId = crypto.randomUUID();
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
@@ -1189,6 +1372,7 @@ export const useSftpTransfers = ({
|
||||
isDirectory: file.isDirectory,
|
||||
progressMode: file.isDirectory ? "files" : "bytes",
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
@@ -1234,77 +1418,66 @@ export const useSftpTransfers = ({
|
||||
releasePausedTransfer(transferId);
|
||||
globalSftpTransferScheduler.cancel(transferId);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
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));
|
||||
markTransferCancelledTree(transferId, [...childIds]);
|
||||
conflictsRef.current = conflictsRef.current.filter((conflict) =>
|
||||
conflict.transferId !== transferId && !childIds.has(conflict.transferId));
|
||||
setConflicts(conflictsRef.current);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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 */ }
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
|
||||
[cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers],
|
||||
);
|
||||
|
||||
// Soft pause/resume: single TransferRuntime entry (store soft-control +
|
||||
// dedicated hard reconnect). No panel-local soft-control dual path.
|
||||
// All controls go through the process-global runtime, including cancellation
|
||||
// settlement. A panel must never clear cancellation latches on Resume.
|
||||
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);
|
||||
@@ -2081,7 +2254,6 @@ 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,
|
||||
@@ -2106,7 +2278,7 @@ export const useSftpTransfers = ({
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
cancelTransfer,
|
||||
cancelTransfer: transferRuntime.cancel,
|
||||
// Single process-level control surface (TransferRuntime).
|
||||
pauseTransfer: async (transferId: string) => {
|
||||
await transferRuntime.pause(transferId);
|
||||
|
||||
@@ -1622,6 +1622,46 @@ 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,
|
||||
@@ -1650,6 +1690,10 @@ 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");
|
||||
});
|
||||
|
||||
@@ -2706,6 +2750,39 @@ 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();
|
||||
@@ -3867,3 +3944,66 @@ 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");
|
||||
}
|
||||
});
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
settleTransferControlEpochTree,
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry";
|
||||
import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome";
|
||||
import {
|
||||
clearTransferCancelledTree,
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
@@ -93,6 +95,12 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{
|
||||
resetCheckpoint?: boolean;
|
||||
}>;
|
||||
|
||||
interface RetainedCancellationTask {
|
||||
task: TransferTask;
|
||||
read(): TransferTask | undefined;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface SftpTransferCenterStore {
|
||||
subscribe(listener: Listener): () => void;
|
||||
/**
|
||||
@@ -121,7 +129,10 @@ 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;
|
||||
@@ -464,6 +475,8 @@ 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>();
|
||||
@@ -818,8 +831,7 @@ 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 !== "attention";
|
||||
&& task.status !== "interrupted";
|
||||
if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (action === "pause") {
|
||||
@@ -879,8 +891,17 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (!result.success && result.reason) notify.warning(result.reason, "SFTP");
|
||||
return;
|
||||
}
|
||||
const result = await (bridge?.cancelCompressedUpload?.(taskId)
|
||||
?? { success: false });
|
||||
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;
|
||||
}
|
||||
if (!result.success) {
|
||||
const reason = "Could not cancel the compressed upload.";
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
@@ -889,6 +910,13 @@ 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;
|
||||
@@ -1301,9 +1329,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
emit();
|
||||
}
|
||||
}
|
||||
if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) {
|
||||
if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId)
|
||||
.filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status))
|
||||
.map((candidate) => candidate.id);
|
||||
// Stop surviving processTransfer walks immediately (panel-local
|
||||
// cancelledTasksRef is gone after unmount).
|
||||
@@ -1315,33 +1343,43 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await netcattyBridge.get()?.cancelTransfer?.(taskId);
|
||||
for (const childId of childIds) {
|
||||
try { await netcattyBridge.get()?.cancelTransfer?.(childId); } 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 */ }
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
@@ -1794,10 +1832,28 @@ 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();
|
||||
@@ -1913,9 +1969,52 @@ 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);
|
||||
return invoke(taskId, "cancel");
|
||||
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;
|
||||
},
|
||||
async retry(taskId) {
|
||||
const ownerId = findOwner(taskId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
@@ -42,6 +43,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
showTrayIcon: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
@@ -62,6 +64,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
showTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
@@ -173,6 +176,24 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync the show-tray-icon setting. Runs after the close-to-tray
|
||||
// effect above (declaration order) so a hidden icon destroys the tray that
|
||||
// close-to-tray's mount-time push may have just created.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Update main process tray visibility (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setShowTrayIcon) {
|
||||
bridge.setShowTrayIcon(showTrayIcon).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set show-tray-icon:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon);
|
||||
}, [enabled, showTrayIcon, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-launch from the main process on mount — the OS login item
|
||||
// is the real source of truth (the user may have toggled it outside the
|
||||
// app), localStorage is only an optimistic cache for first paint.
|
||||
|
||||
Loaded 100 of 595 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user