mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
158
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3107e50cbb | ||
|
|
7e48780a34 | ||
|
|
2bb52ce6ee | ||
|
|
c2c4b6ec77 | ||
|
|
3f35872638 | ||
|
|
bace30578c | ||
|
|
8aeb6ade6f | ||
|
|
b6bff4df6b | ||
|
|
2cfe2a05ea | ||
|
|
7ed7250f34 | ||
|
|
2ce0164fa9 | ||
|
|
2e55b9348f | ||
|
|
1e79dadbe5 | ||
|
|
52e15f8df7 | ||
|
|
7497e64ab4 | ||
|
|
9888f07cb2 | ||
|
|
293ca1098e | ||
|
|
35a47fe95a | ||
|
|
82add1bda6 | ||
|
|
239804bf62 | ||
|
|
460d9bb7c6 | ||
|
|
b7996c58e3 | ||
|
|
ab50ad93b7 | ||
|
|
c86116ac08 | ||
|
|
5ac8c8bf1f | ||
|
|
b99de934a3 | ||
|
|
8e88b99131 | ||
|
|
d7122683d2 | ||
|
|
2c67e3c3ce | ||
|
|
7e9e9b53eb | ||
|
|
e8bbc9a5c9 | ||
|
|
3de34cae5d | ||
|
|
7851ddb01e | ||
|
|
de517a6237 | ||
|
|
aca81953fe | ||
|
|
9a484ba3cb | ||
|
|
dfc7961b68 | ||
|
|
41d2a2d4af | ||
|
|
af9ee5d697 | ||
|
|
2aae278d53 | ||
|
|
d9632ab985 | ||
|
|
82ee429eab | ||
|
|
b70abfd70d | ||
|
|
1fe313b229 | ||
|
|
fd559eb095 | ||
|
|
3f2b877ba6 | ||
|
|
707a8749c9 | ||
|
|
2d5f90ac2e | ||
|
|
d0e024625c | ||
|
|
9a8f9bb0c6 | ||
|
|
b512133209 | ||
|
|
83ee53ba96 | ||
|
|
fa07263628 | ||
|
|
08c0ac4f59 | ||
|
|
d298275378 | ||
|
|
cdf9b334d5 | ||
|
|
158f204baa | ||
|
|
1c34384651 | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd | ||
|
|
cc001c9a4b | ||
|
|
b81b27f817 | ||
|
|
3a2bf6498d | ||
|
|
53a4bf29b0 | ||
|
|
0ed481c397 | ||
|
|
e85c1fea30 | ||
|
|
6c2bf43ee5 | ||
|
|
abdf254702 | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec | ||
|
|
71373d1ad1 | ||
|
|
b4e056dd4a | ||
|
|
d5c7d3fbf4 | ||
|
|
1ab07ffee4 | ||
|
|
4af4af274b | ||
|
|
e8ad5b3d29 | ||
|
|
b7953a6b26 | ||
|
|
0a01af5144 | ||
|
|
848f201cf9 | ||
|
|
5c901c6b04 | ||
|
|
de3410f6e1 | ||
|
|
a76971e2c5 | ||
|
|
84bf1cbfff | ||
|
|
d4d07d48af | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
1ff40e859c | ||
|
|
edf07fc74e | ||
|
|
6a1bea2bce | ||
|
|
7a1f415a91 | ||
|
|
745f6a3120 | ||
|
|
19faa554a4 | ||
|
|
c0a66f1e88 | ||
|
|
6a79400112 | ||
|
|
13bcf47282 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 | ||
|
|
e5abae559c | ||
|
|
d653180c3c | ||
|
|
0006634f6b | ||
|
|
729ffcf162 | ||
|
|
e642c4953f | ||
|
|
cb737ba0c9 | ||
|
|
c6a1f11fc9 | ||
|
|
483410b132 | ||
|
|
5ed58345aa | ||
|
|
5f29d91499 | ||
|
|
5d9c8f29de | ||
|
|
201ebcb21a | ||
|
|
6ac6c3dbfa | ||
|
|
11f2b7dbd8 | ||
|
|
af574078e7 | ||
|
|
355d53abe3 | ||
|
|
c89e4c7246 | ||
|
|
282a557f67 | ||
|
|
1ee0f8441c | ||
|
|
c396fc78ef | ||
|
|
c48514381e | ||
|
|
5ae5965556 | ||
|
|
0050e895df | ||
|
|
060fbbb2d1 | ||
|
|
8c64280d87 | ||
|
|
422bf120a9 | ||
|
|
555e911887 | ||
|
|
74b7ce6c8c | ||
|
|
3b78e097e6 | ||
|
|
7cb3e2e2ad | ||
|
|
6052cbbd1f | ||
|
|
ecd988ea4c | ||
|
|
258d72bdca | ||
|
|
157e5f03f0 | ||
|
|
d2b1d5fa20 | ||
|
|
cc6ba30891 | ||
|
|
2fefdf6f0c | ||
|
|
01d6c4ceaf | ||
|
|
9b7d2a6a34 | ||
|
|
d79a4de401 | ||
|
|
9a17691c32 | ||
|
|
c22bf0ab9b | ||
|
|
494cd57e25 | ||
|
|
f0169ad632 | ||
|
|
df74164d54 | ||
|
|
565ab504f4 | ||
|
|
9958ee5097 |
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: |
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -45,9 +47,11 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useMonitorRuntime();
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
</AppLocalStateProvider>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -699,6 +699,89 @@ test('connection log host snapshot includes custom host icon fields', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleOpacity hotkey invokes toggleWindowOpacity', () => {
|
||||
let called = false;
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWindowOpacity: () => { called = true; },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}),
|
||||
'toggleOpacity',
|
||||
{ key: 'u', code: 'KeyU', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'IME process key with its original physical code', key: 'Process', keyCode: 229, isComposing: false, expected: false },
|
||||
{ name: 'key during composition', key: 'U', keyCode: 85, isComposing: true, expected: false },
|
||||
{ name: 'ordinary shortcut', key: 'U', keyCode: 85, isComposing: false, expected: true },
|
||||
]) {
|
||||
test(`transparency shortcut respects ${scenario.name}`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = {
|
||||
...scenario,
|
||||
code: 'KeyU',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => actions.push(action),
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), event);
|
||||
assert.deepEqual(actions, scenario.expected ? ['toggleOpacity'] : []);
|
||||
assert.equal(prevented, scenario.expected);
|
||||
});
|
||||
}
|
||||
for (const scenario of [
|
||||
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
|
||||
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -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(),
|
||||
@@ -302,6 +306,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
// IME-filtered keys retain their physical code (e.g. Linux Unicode entry).
|
||||
// Let composition handle them instead of treating them as opacity toggles.
|
||||
if (binding.action === 'toggleOpacity' && (e.isComposing || e.keyCode === 229 || e.key === 'Process')) {
|
||||
return;
|
||||
}
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
@@ -777,7 +786,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -967,6 +976,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleOpacity':
|
||||
toggleWindowOpacity();
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
@@ -1104,11 +1116,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') {
|
||||
@@ -1138,7 +1154,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(),
|
||||
@@ -1186,8 +1205,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,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');
|
||||
});
|
||||
}
|
||||
@@ -38,9 +38,11 @@ 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';
|
||||
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
@@ -53,6 +55,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';
|
||||
@@ -149,6 +152,7 @@ export function AppSideEffects() {
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
toggleWindowOpacity,
|
||||
} = settings;
|
||||
|
||||
// Always publish terminal keyboard focus to the main process. When font zoom
|
||||
@@ -269,22 +273,6 @@ export function AppSideEffects() {
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
// AppSideEffects only needs host/session maps for glue handlers and effects.
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
},
|
||||
[runSnippet],
|
||||
);
|
||||
|
||||
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
|
||||
@@ -1093,6 +1081,7 @@ export function AppSideEffects() {
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
toggleWorkspaceViewMode,
|
||||
workspaces: workspacesRefForHotkeys.current,
|
||||
}), action, e);
|
||||
@@ -1105,6 +1094,7 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
moveFocusInWorkspace,
|
||||
toggleBroadcast,
|
||||
toggleWindowOpacity,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
confirmIfBusyLocalTerminal,
|
||||
@@ -1210,11 +1200,16 @@ export function AppSideEffects() {
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
|
||||
const handleConfirmDeleteHost = useCallback(async () => {
|
||||
if (!deleteHostConfirm) return;
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
try {
|
||||
await deleteVaultHosts([deleteHostConfirm.hostId]);
|
||||
setDeleteHostConfirm(null);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}, [deleteHostConfirm, deleteVaultHosts, t]);
|
||||
|
||||
const handleCancelDeleteHost = useCallback(() => {
|
||||
setDeleteHostConfirm(null);
|
||||
@@ -1366,8 +1361,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));
|
||||
@@ -1398,14 +1417,17 @@ 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);
|
||||
@@ -1432,6 +1454,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.` };
|
||||
@@ -1474,6 +1500,7 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -37,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';
|
||||
@@ -260,7 +261,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
@@ -354,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);
|
||||
@@ -746,6 +749,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onClearCommandHistory={clearAllCommandHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function VaultHost() {
|
||||
return {
|
||||
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
clearAllCommandHistory: actions?.clearAllCommandHistory,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
|
||||
@@ -72,6 +72,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
} = vault;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -144,12 +145,14 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
});
|
||||
return () => {
|
||||
registerVaultSnapshotActions(null);
|
||||
};
|
||||
}, [
|
||||
addShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
|
||||
@@ -542,7 +542,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ConvergentMigrationProviderInput,
|
||||
} from '../domain/convergentSync';
|
||||
import { isProviderReadyForSync } from '../domain/sync';
|
||||
import { isPreserveDeletionsStrategy } from '../domain/syncStrategy';
|
||||
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
|
||||
import { applyProtectedSyncPayload } from './localVaultBackups';
|
||||
@@ -113,6 +114,7 @@ export async function prepareConvergentSyncMigration(
|
||||
providers: inputs,
|
||||
deviceId: manager.getState().deviceId,
|
||||
now,
|
||||
preserveDeletions: isPreserveDeletionsStrategy(manager.getState().syncStrategy),
|
||||
});
|
||||
if (plan.state) {
|
||||
for (const seed of legacyBaselineSeeds) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { startPortForward } from '../infrastructure/services/portForwardingService';
|
||||
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
|
||||
interface OpenHostBrowserOptions {
|
||||
host: Host;
|
||||
sessionId?: string;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
const pendingOpens = new Map<string, Promise<void>>();
|
||||
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
|
||||
|
||||
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
|
||||
const value = style.getPropertyValue(`--${token}`).trim();
|
||||
return isValidHslToken(value) ? [[token, value]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
|
||||
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
|
||||
}
|
||||
|
||||
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
|
||||
await bridge.openHostBrowserUrl({ browserId, url });
|
||||
}
|
||||
|
||||
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
|
||||
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
|
||||
const existing = pendingOpens.get(options.browserId);
|
||||
if (existing) return existing;
|
||||
const opening = (async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
|
||||
throw new Error('The host browser is unavailable in this build');
|
||||
}
|
||||
const local = host.protocol === 'local';
|
||||
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
|
||||
id: `host-browser-${crypto.randomUUID()}`,
|
||||
label: `Browser: ${host.label || host.hostname}`,
|
||||
type: 'dynamic',
|
||||
hostId: host.id,
|
||||
localPort: 0,
|
||||
bindAddress: '127.0.0.1',
|
||||
status: 'inactive',
|
||||
createdAt: Date.now(),
|
||||
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
|
||||
knownHosts, { dedicatedTransport: true, prepareOnly: true });
|
||||
if (!result.success || (!local && !result.connectionOptions)) {
|
||||
throw new Error(result.error || 'Could not prepare the browser connection');
|
||||
}
|
||||
await bridge.openHostBrowser({
|
||||
browserId: options.browserId,
|
||||
embedded: true,
|
||||
local,
|
||||
hostId: host.id,
|
||||
label: host.label || host.hostname,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
themeTokens: currentBrowserThemeTokens(),
|
||||
connectionOptions: result.connectionOptions,
|
||||
});
|
||||
})();
|
||||
pendingOpens.set(options.browserId, opening);
|
||||
void opening.finally(() => {
|
||||
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
|
||||
}).catch(() => {});
|
||||
return opening;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
|
||||
let cachedBookmarks: BrowserBookmark[] | undefined;
|
||||
|
||||
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
|
||||
return cachedBookmarks?.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
|
||||
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
|
||||
if (!reader) return cachedBookmarks;
|
||||
const items = await reader();
|
||||
cachedBookmarks = items;
|
||||
return getCachedHostBrowserBookmarks();
|
||||
}
|
||||
|
||||
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
|
||||
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
|
||||
if (!writer) return;
|
||||
await writer(items);
|
||||
cachedBookmarks = items;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
const keys = [
|
||||
'cloudSync.provider.disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle',
|
||||
'cloudSync.provider.disconnect.confirmMessage',
|
||||
'cloudSync.provider.disconnect.confirmAction',
|
||||
'cloudSync.provider.disconnect.toastTitle',
|
||||
'cloudSync.provider.disconnect.cloudRemains',
|
||||
'cloudSync.provider.disconnect.failed',
|
||||
] as const;
|
||||
|
||||
test('disconnect copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect confirm copy states that saved provider credentials are removed', () => {
|
||||
// The confirm dialog must explain the data consequence of disconnecting:
|
||||
// locally saved endpoint + credentials removal, not just "stops syncing".
|
||||
const expected = [
|
||||
[en, 'will be removed'],
|
||||
[es, 'eliminarán'],
|
||||
[ru, 'будут удалены'],
|
||||
[zhCN, '删除'],
|
||||
[zhTW, '刪除'],
|
||||
] as const;
|
||||
for (const [messages, fragment] of expected) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
|
||||
`confirmMessage should mention credential removal: ${fragment}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect toast copy explains the cloud copy is retained', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
|
||||
'{name}',
|
||||
),
|
||||
`${locale} cloudRemains hint should reference the provider name`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect copy distinguishes login cleanup from retained local data', () => {
|
||||
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
|
||||
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
|
||||
for (const messages of [en, ru, es, zhCN, zhTW]) {
|
||||
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
|
||||
}
|
||||
});
|
||||
@@ -11,10 +11,16 @@ const strategyKeys = [
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.smartMergePreserve",
|
||||
"cloudSync.strategy.smartMergePreserveDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
"cloudSync.aiSessions.title",
|
||||
"cloudSync.aiSessions.desc",
|
||||
"cloudSync.aiSessions.compatibility",
|
||||
"cloudSync.aiSessions.enableFailed",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enMonitorMessages } from './en/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
@@ -9,6 +10,7 @@ import { enScriptsMessages } from './en/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const en: Messages = {
|
||||
...enMonitorMessages,
|
||||
...enCoreMessages,
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
@@ -513,6 +514,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Live catalog',
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
@@ -522,13 +527,21 @@ 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.forkFromHere': 'Fork from here',
|
||||
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
|
||||
'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.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -767,6 +767,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Browser',
|
||||
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
@@ -1123,6 +1128,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',
|
||||
@@ -1148,6 +1154,9 @@ Highlight the focused split pane:
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
"monitor.password": "Password (this run only)",
|
||||
"monitor.status.limit":
|
||||
"Monitoring is limited to 256 unique hosts at a time.",
|
||||
"sync.entityType.monitorPanels": "monitor panels",
|
||||
"monitor.title": "Monitoring",
|
||||
"monitor.create": "New panel",
|
||||
"monitor.createFirst": "Create your first panel",
|
||||
"monitor.empty": "Your hosts, at a glance",
|
||||
"monitor.emptyHint":
|
||||
"Choose saved hosts from any group. Start monitoring when you are ready.",
|
||||
"monitor.choosePanel": "Choose a panel",
|
||||
"monitor.edit": "Edit panel",
|
||||
"monitor.copy": "Duplicate panel",
|
||||
"monitor.copyName": "{name} copy",
|
||||
"monitor.delete": "Delete panel",
|
||||
"monitor.deleteHint":
|
||||
"Delete “{name}”? Its monitoring will stop. Saved hosts and other panels are kept.",
|
||||
"monitor.start": "Start monitoring",
|
||||
"monitor.stop": "Stop",
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
"monitor.minutes": "Last {count} min",
|
||||
"monitor.sort": "Sort hosts",
|
||||
"monitor.sort.manual": "Saved order",
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Cores: {count}",
|
||||
"monitor.memory": "Memory",
|
||||
"monitor.diskCapacity": "{used} used / {total} total",
|
||||
"monitor.diskCapacityUnknown": "Capacity unavailable",
|
||||
"monitor.rootDisk": "Root partition",
|
||||
"monitor.network": "Network",
|
||||
"monitor.download": "Download",
|
||||
"monitor.upload": "Upload",
|
||||
"monitor.updated": "Updated",
|
||||
"monitor.updatedAt": "Updated {time}",
|
||||
"monitor.noSamples": "Waiting for the first reading",
|
||||
"monitor.cpuTrend": "CPU trend",
|
||||
"monitor.memoryTrend": "Memory trend",
|
||||
"monitor.downloadTrend": "Download trend",
|
||||
"monitor.uploadTrend": "Upload trend",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
|
||||
"monitor.name": "Panel name",
|
||||
"monitor.description": "Description (optional)",
|
||||
"monitor.chooseHosts": "Choose hosts",
|
||||
"monitor.selectionHint":
|
||||
"Choose across groups. Unsupported connections are shown with a clear status when started.",
|
||||
"monitor.defaultView": "Default view",
|
||||
"monitor.saveHint":
|
||||
"Saving keeps configuration only and does not connect. Copies start stopped too.",
|
||||
"monitor.nameRequired": "Enter a panel name.",
|
||||
"monitor.hostRequired": "Select at least one host.",
|
||||
"monitor.duplicateName": "A panel already uses this name.",
|
||||
"monitor.saveFailed": "Could not save the panel. Please try again.",
|
||||
"monitor.deleted":
|
||||
"This panel was deleted in another window. Close the editor and create a new panel.",
|
||||
"monitor.moveUp": "Move host up",
|
||||
"monitor.removeHost": "Remove from panel",
|
||||
"monitor.missingHost": "Deleted host",
|
||||
"monitor.observed": "Observed for {count} min this run",
|
||||
"monitor.disks": "Partitions",
|
||||
"monitor.system": "System information",
|
||||
"monitor.loginHint":
|
||||
"Check the host credentials or connection settings, then retry. Identity and sign-in prompts are handled one host at a time.",
|
||||
"monitor.retry": "Retry connection",
|
||||
"monitor.unsupportedHint":
|
||||
"Monitoring needs Linux or macOS over SSH with support for additional command channels.",
|
||||
"monitor.openTerminal": "Open terminal",
|
||||
"monitor.confirmHost": "Confirm host identity",
|
||||
"monitor.status.stopped": "Not started",
|
||||
"monitor.status.queued": "Queued",
|
||||
"monitor.status.connecting": "Connecting",
|
||||
"monitor.status.live": "Up to date",
|
||||
"monitor.status.login": "Sign-in needed",
|
||||
"monitor.status.error": "Collection failed",
|
||||
"monitor.status.unsupported": "Unavailable",
|
||||
"monitor.status.disconnected": "Disconnected",
|
||||
"monitor.status.stale": "Out of date",
|
||||
"monitor.status.missing": "Host deleted",
|
||||
};
|
||||
@@ -20,6 +20,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.browser.title': 'Browser',
|
||||
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
|
||||
'terminal.browser.open': 'Open Browser',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
@@ -44,6 +47,10 @@ export const enTerminalMessages: Messages = {
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'history.action.delete': 'Delete from history',
|
||||
'history.action.clearAll': 'Clear all history',
|
||||
'history.clearAll.dialog.title': 'Clear all command history?',
|
||||
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
|
||||
'history.clearAll.dialog.confirm': 'Clear all',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
@@ -310,8 +317,11 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Connecting...',
|
||||
'cloudSync.provider.disconnect': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
|
||||
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'S3 Compatible',
|
||||
@@ -381,6 +391,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Sync strategy',
|
||||
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
|
||||
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
|
||||
'cloudSync.aiSessions.title': 'Sync AI chat history',
|
||||
'cloudSync.aiSessions.desc': 'Include AI chat sessions in cloud sync. Messages merge append-only across devices; attachment contents stay on this device.',
|
||||
'cloudSync.aiSessions.compatibility': 'Older versions may overwrite chat history. Use an updated Netcatty on every synced device.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Could not update chat sync. Check the cloud connection and available local storage, then try again.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Smart merge (never delete)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combine changes from both sides and never propagate a one-sided deletion — an entry survives until it is removed on every device. Recommended when you restore backups or keep several machines in sync.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
|
||||
'cloudSync.strategy.preferCloud': 'Cloud wins',
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
@@ -518,6 +534,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
|
||||
@@ -1018,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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { esMonitorMessages } from './es/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
@@ -9,6 +10,7 @@ import { esScriptsMessages } from './es/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esMonitorMessages,
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
@@ -504,6 +505,10 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Catálogo en vivo',
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
@@ -513,13 +518,21 @@ 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.forkFromHere': 'Bifurcar desde aquí',
|
||||
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
|
||||
'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.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -682,6 +682,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Navegador',
|
||||
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
|
||||
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
|
||||
@@ -883,6 +888,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.new-workspace': 'Nuevo espacio de trabajo',
|
||||
'settings.shortcuts.binding.snippets': 'Abrir fragmentos',
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
@@ -1081,6 +1087,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',
|
||||
@@ -1106,6 +1113,9 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Conectar ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.actions": "Acciones del panel",
|
||||
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
|
||||
"monitor.username": "Usuario",
|
||||
"monitor.password": "Contraseña (solo esta ejecución)",
|
||||
"monitor.status.limit":
|
||||
"Se pueden monitorizar hasta 256 equipos únicos a la vez.",
|
||||
"sync.entityType.monitorPanels": "paneles de monitorización",
|
||||
"monitor.title": "Monitorización",
|
||||
"monitor.create": "Nuevo panel",
|
||||
"monitor.createFirst": "Crear el primer panel",
|
||||
"monitor.empty": "Tus equipos de un vistazo",
|
||||
"monitor.emptyHint":
|
||||
"Elige equipos guardados de cualquier grupo e inicia cuando quieras.",
|
||||
"monitor.choosePanel": "Elegir panel",
|
||||
"monitor.edit": "Editar panel",
|
||||
"monitor.copy": "Duplicar panel",
|
||||
"monitor.copyName": "Copia de {name}",
|
||||
"monitor.delete": "Eliminar panel",
|
||||
"monitor.deleteHint":
|
||||
"¿Eliminar «{name}»? Se detendrá su monitorización. Se conservarán los equipos y otros paneles.",
|
||||
"monitor.start": "Iniciar monitorización",
|
||||
"monitor.stop": "Detener",
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
"monitor.minutes": "Últimos {count} min",
|
||||
"monitor.sort": "Ordenar equipos",
|
||||
"monitor.sort.manual": "Orden guardado",
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Núcleos: {count}",
|
||||
"monitor.memory": "Memoria",
|
||||
"monitor.diskCapacity": "{used} usados / {total} en total",
|
||||
"monitor.diskCapacityUnknown": "Capacidad no disponible",
|
||||
"monitor.rootDisk": "Partición raíz",
|
||||
"monitor.network": "Red",
|
||||
"monitor.download": "Descarga",
|
||||
"monitor.upload": "Subida",
|
||||
"monitor.updated": "Actualizado",
|
||||
"monitor.updatedAt": "Actualizado: {time}",
|
||||
"monitor.noSamples": "Esperando la primera lectura",
|
||||
"monitor.cpuTrend": "Tendencia de CPU",
|
||||
"monitor.memoryTrend": "Tendencia de memoria",
|
||||
"monitor.downloadTrend": "Tendencia de descarga",
|
||||
"monitor.uploadTrend": "Tendencia de subida",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
|
||||
"monitor.name": "Nombre del panel",
|
||||
"monitor.description": "Descripción (opcional)",
|
||||
"monitor.chooseHosts": "Elegir equipos",
|
||||
"monitor.selectionHint":
|
||||
"Selecciona entre grupos. Las conexiones no compatibles se indicarán al iniciar.",
|
||||
"monitor.defaultView": "Vista predeterminada",
|
||||
"monitor.saveHint":
|
||||
"Guardar conserva la configuración sin conectar. Las copias también empiezan detenidas.",
|
||||
"monitor.nameRequired": "Escribe un nombre para el panel.",
|
||||
"monitor.hostRequired": "Selecciona al menos un equipo.",
|
||||
"monitor.duplicateName": "Ya existe un panel con ese nombre.",
|
||||
"monitor.saveFailed": "No se pudo guardar el panel. Inténtalo de nuevo.",
|
||||
"monitor.deleted":
|
||||
"El panel se eliminó en otra ventana. Cierra el editor y crea uno nuevo.",
|
||||
"monitor.moveUp": "Subir equipo",
|
||||
"monitor.removeHost": "Quitar del panel",
|
||||
"monitor.missingHost": "Equipo eliminado",
|
||||
"monitor.observed": "Observado durante {count} min",
|
||||
"monitor.disks": "Particiones",
|
||||
"monitor.system": "Información del sistema",
|
||||
"monitor.loginHint":
|
||||
"Comprueba las credenciales o la conexión y reintenta. Las solicitudes se procesan equipo por equipo.",
|
||||
"monitor.retry": "Reintentar conexión",
|
||||
"monitor.unsupportedHint":
|
||||
"Se necesita Linux o macOS por SSH con canales de comandos adicionales.",
|
||||
"monitor.openTerminal": "Abrir terminal",
|
||||
"monitor.confirmHost": "Confirmar identidad",
|
||||
"monitor.status.stopped": "Sin iniciar",
|
||||
"monitor.status.queued": "En cola",
|
||||
"monitor.status.connecting": "Conectando",
|
||||
"monitor.status.live": "Actualizado",
|
||||
"monitor.status.login": "Requiere acceso",
|
||||
"monitor.status.error": "Error de lectura",
|
||||
"monitor.status.unsupported": "No disponible",
|
||||
"monitor.status.disconnected": "Desconectado",
|
||||
"monitor.status.stale": "Sin actualizar",
|
||||
"monitor.status.missing": "Equipo eliminado",
|
||||
};
|
||||
@@ -20,6 +20,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.browser.title': 'Navegador',
|
||||
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
|
||||
'terminal.browser.open': 'Abrir navegador',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
@@ -44,6 +47,10 @@ export const esTerminalMessages: Messages = {
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'history.action.clearAll': 'Borrar todo el historial',
|
||||
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
|
||||
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
|
||||
'history.clearAll.dialog.confirm': 'Borrar todo',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
@@ -309,8 +316,11 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.disconnect': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
|
||||
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
@@ -380,6 +390,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.aiSessions.title': 'Sincronizar el historial de chats de IA',
|
||||
'cloudSync.aiSessions.desc': 'Incluye las sesiones de chat de IA en la sincronización en la nube. Los mensajes se combinan de forma incremental entre dispositivos; el contenido de los adjuntos permanece en este dispositivo.',
|
||||
'cloudSync.aiSessions.compatibility': 'Las versiones antiguas pueden sobrescribir el historial. Actualiza Netcatty en todos los dispositivos sincronizados.',
|
||||
'cloudSync.aiSessions.enableFailed': 'No se pudo actualizar la sincronización del chat. Comprueba la conexión con la nube y el espacio local disponible e inténtalo de nuevo.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Fusión inteligente (sin borrar)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combina los cambios de ambos lados y nunca propaga un borrado de un solo lado: una entrada se conserva hasta que se elimina en todos los dispositivos. Recomendado al restaurar copias de seguridad o al usar varios equipos.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
@@ -517,6 +533,7 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
|
||||
@@ -1008,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',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { enMonitorMessages } from "./en/monitor";
|
||||
import { esMonitorMessages } from "./es/monitor";
|
||||
import { ruMonitorMessages } from "./ru/monitor";
|
||||
import { zhCNMonitorMessages } from "./zh-CN/monitor";
|
||||
import { zhTWMonitorMessages } from "./zh-TW/monitor";
|
||||
|
||||
test("monitoring UI literal message keys exist, including editor-only controls", () => {
|
||||
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
|
||||
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
|
||||
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
|
||||
for (const key of keys)
|
||||
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
|
||||
}
|
||||
});
|
||||
for (const [locale, messages] of Object.entries({
|
||||
es: esMonitorMessages,
|
||||
ru: ruMonitorMessages,
|
||||
"zh-CN": zhCNMonitorMessages,
|
||||
"zh-TW": zhTWMonitorMessages,
|
||||
})) {
|
||||
test(`monitoring has translated actions, states and matching placeholders in ${locale}`, () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(messages).sort(),
|
||||
Object.keys(enMonitorMessages).sort(),
|
||||
);
|
||||
for (const key of Object.keys(enMonitorMessages)) {
|
||||
const english = enMonitorMessages[key as keyof typeof enMonitorMessages];
|
||||
const translated = messages[key as keyof typeof messages];
|
||||
assert.ok(translated.trim(), key);
|
||||
assert.deepEqual(
|
||||
(translated.match(/\{\w+\}/g) ?? []).sort(),
|
||||
(english.match(/\{\w+\}/g) ?? []).sort(),
|
||||
key,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ruMonitorMessages } from './ru/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
@@ -9,6 +10,7 @@ import { ruScriptsMessages } from './ru/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruMonitorMessages,
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
@@ -460,6 +461,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Актуальный список моделей',
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
@@ -469,13 +474,21 @@ 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.forkFromHere': 'Ветвление отсюда',
|
||||
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
|
||||
@@ -718,6 +718,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Анимировать прокрутку области терминала вместо мгновенного перехода',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Браузер',
|
||||
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
|
||||
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
|
||||
@@ -908,6 +913,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
|
||||
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
"monitor.password": "Пароль (только для этого запуска)",
|
||||
"monitor.status.limit": "Одновременно можно наблюдать не более 256 серверов.",
|
||||
"sync.entityType.monitorPanels": "панели мониторинга",
|
||||
"monitor.title": "Мониторинг",
|
||||
"monitor.create": "Новая панель",
|
||||
"monitor.createFirst": "Создать первую панель",
|
||||
"monitor.empty": "Все серверы перед глазами",
|
||||
"monitor.emptyHint":
|
||||
"Выберите сохранённые серверы из любых групп и запустите мониторинг.",
|
||||
"monitor.choosePanel": "Выбрать панель",
|
||||
"monitor.edit": "Изменить панель",
|
||||
"monitor.copy": "Дублировать панель",
|
||||
"monitor.copyName": "Копия {name}",
|
||||
"monitor.delete": "Удалить панель",
|
||||
"monitor.deleteHint":
|
||||
"Удалить «{name}»? Мониторинг этой панели остановится. Серверы и другие панели сохранятся.",
|
||||
"monitor.start": "Начать мониторинг",
|
||||
"monitor.stop": "Остановить",
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
"monitor.minutes": "Последние {count} мин",
|
||||
"monitor.sort": "Сортировка серверов",
|
||||
"monitor.sort.manual": "Сохранённый порядок",
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
"monitor.cpu": "ЦП",
|
||||
"monitor.cores": "Ядер: {count}",
|
||||
"monitor.memory": "Память",
|
||||
"monitor.diskCapacity": "Использовано {used} / всего {total}",
|
||||
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
|
||||
"monitor.rootDisk": "Корневой раздел",
|
||||
"monitor.network": "Сеть",
|
||||
"monitor.download": "Загрузка",
|
||||
"monitor.upload": "Отправка",
|
||||
"monitor.updated": "Обновлено",
|
||||
"monitor.updatedAt": "Обновлено: {time}",
|
||||
"monitor.noSamples": "Ожидание первого измерения",
|
||||
"monitor.cpuTrend": "График ЦП",
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
"monitor.selectionHint":
|
||||
"Можно выбирать из разных групп. Неподдерживаемые подключения будут отмечены при запуске.",
|
||||
"monitor.defaultView": "Вид по умолчанию",
|
||||
"monitor.saveHint":
|
||||
"Сохранение не подключает серверы. Копии также создаются остановленными.",
|
||||
"monitor.nameRequired": "Введите название панели.",
|
||||
"monitor.hostRequired": "Выберите хотя бы один сервер.",
|
||||
"monitor.duplicateName": "Панель с таким названием уже существует.",
|
||||
"monitor.saveFailed": "Не удалось сохранить панель. Повторите попытку.",
|
||||
"monitor.deleted":
|
||||
"Панель удалена в другом окне. Закройте редактор и создайте новую.",
|
||||
"monitor.moveUp": "Переместить выше",
|
||||
"monitor.removeHost": "Убрать из панели",
|
||||
"monitor.missingHost": "Удалённый сервер",
|
||||
"monitor.observed": "Наблюдение: {count} мин",
|
||||
"monitor.disks": "Разделы дисков",
|
||||
"monitor.system": "Информация о системе",
|
||||
"monitor.loginHint":
|
||||
"Проверьте данные входа и настройки подключения. Подтверждения обрабатываются по одному серверу.",
|
||||
"monitor.retry": "Повторить подключение",
|
||||
"monitor.unsupportedHint":
|
||||
"Для мониторинга нужен Linux или macOS по SSH с дополнительными каналами команд.",
|
||||
"monitor.openTerminal": "Открыть терминал",
|
||||
"monitor.confirmHost": "Подтвердить сервер",
|
||||
"monitor.status.stopped": "Не запущено",
|
||||
"monitor.status.queued": "В очереди",
|
||||
"monitor.status.connecting": "Подключение",
|
||||
"monitor.status.live": "Актуально",
|
||||
"monitor.status.login": "Нужен вход",
|
||||
"monitor.status.error": "Ошибка сбора",
|
||||
"monitor.status.unsupported": "Недоступно",
|
||||
"monitor.status.disconnected": "Отключено",
|
||||
"monitor.status.stale": "Устарело",
|
||||
"monitor.status.missing": "Сервер удалён",
|
||||
};
|
||||
@@ -41,6 +41,9 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.browser.title': 'Браузер',
|
||||
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
|
||||
'terminal.browser.open': 'Открыть браузер',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
@@ -65,6 +68,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'history.action.delete': 'Удалить из истории',
|
||||
'history.action.clearAll': 'Очистить всю историю',
|
||||
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
|
||||
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
|
||||
'history.clearAll.dialog.confirm': 'Очистить всё',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
@@ -328,8 +335,11 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Подключение...',
|
||||
'cloudSync.provider.disconnect': 'Отключить',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
|
||||
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'Совместимое с S3',
|
||||
@@ -399,6 +409,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Стратегия синхронизации',
|
||||
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
|
||||
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
|
||||
'cloudSync.aiSessions.title': 'Синхронизировать историю ИИ-чатов',
|
||||
'cloudSync.aiSessions.desc': 'Включать сессии ИИ-чатов в облачную синхронизацию. Сообщения объединяются дополнением между устройствами; содержимое вложений остаётся на этом устройстве.',
|
||||
'cloudSync.aiSessions.compatibility': 'Старые версии могут перезаписать историю. Обновите Netcatty на всех синхронизируемых устройствах.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Не удалось изменить синхронизацию чатов. Проверьте подключение к облаку и свободное место на устройстве, затем повторите попытку.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Умное слияние (без удалений)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Объединяет изменения с обеих сторон и никогда не передаёт удаление с одной стороны: запись сохраняется, пока её не убрали на всех устройствах. Рекомендуется при восстановлении из резервных копий или при работе с несколькими машинами.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
|
||||
'cloudSync.strategy.preferCloud': 'Приоритет облака',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
@@ -536,6 +552,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
|
||||
@@ -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': 'Экспорт',
|
||||
@@ -127,6 +128,9 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -1051,6 +1055,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,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`);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhCNMonitorMessages } from './zh-CN/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhCN: Messages = {
|
||||
...zhCNMonitorMessages,
|
||||
...zhCNCoreMessages,
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
@@ -513,6 +514,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '实时目录',
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
@@ -522,13 +527,21 @@ 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.forkFromHere': '从此处分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
|
||||
@@ -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': '导出',
|
||||
@@ -765,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
"monitor.password": "密码(仅本次使用)",
|
||||
"monitor.status.limit": "同时最多监控 256 台不同主机。",
|
||||
"sync.entityType.monitorPanels": "监控面板",
|
||||
"monitor.title": "监控",
|
||||
"monitor.create": "新建面板",
|
||||
"monitor.createFirst": "创建第一个监控面板",
|
||||
"monitor.empty": "一眼查看多台主机",
|
||||
"monitor.emptyHint": "跨分组选择已有主机,准备好后再开始监控。",
|
||||
"monitor.choosePanel": "选择面板",
|
||||
"monitor.edit": "编辑面板",
|
||||
"monitor.copy": "复制面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "删除面板",
|
||||
"monitor.deleteHint":
|
||||
"删除“{name}”?该面板将停止监控,已有主机和其他面板会保留。",
|
||||
"monitor.start": "开始监控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分钟",
|
||||
"monitor.sort": "主机排序",
|
||||
"monitor.sort.manual": "保存的顺序",
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核",
|
||||
"monitor.memory": "内存",
|
||||
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暂无容量读数",
|
||||
"monitor.rootDisk": "根分区",
|
||||
"monitor.network": "网络",
|
||||
"monitor.download": "下载",
|
||||
"monitor.upload": "上传",
|
||||
"monitor.updated": "更新时间",
|
||||
"monitor.updatedAt": "更新于 {time}",
|
||||
"monitor.noSamples": "等待第一次读数",
|
||||
"monitor.cpuTrend": "CPU 趋势",
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
"monitor.selectionHint": "可跨分组选择。开始后会明确标出不支持的连接。",
|
||||
"monitor.defaultView": "默认展示",
|
||||
"monitor.saveHint": "保存仅保留配置,不会连接。复制的面板也不会自动开始。",
|
||||
"monitor.nameRequired": "请输入面板名称。",
|
||||
"monitor.hostRequired": "请至少选择一台主机。",
|
||||
"monitor.duplicateName": "已有同名面板,请换个名称。",
|
||||
"monitor.saveFailed": "面板未能保存,请重试。",
|
||||
"monitor.deleted": "该面板已在其他窗口删除。请关闭编辑后新建面板。",
|
||||
"monitor.moveUp": "主机上移",
|
||||
"monitor.removeHost": "从面板移除",
|
||||
"monitor.missingHost": "已删除的主机",
|
||||
"monitor.observed": "本次已观察 {count} 分钟",
|
||||
"monitor.disks": "磁盘分区",
|
||||
"monitor.system": "系统信息",
|
||||
"monitor.loginHint":
|
||||
"请检查主机的登录信息或连接设置后重试。身份确认和登录提示会逐台处理。",
|
||||
"monitor.retry": "重试连接",
|
||||
"monitor.unsupportedHint":
|
||||
"监控需要支持额外命令通道的 Linux 或 macOS SSH 连接。",
|
||||
"monitor.openTerminal": "打开终端",
|
||||
"monitor.confirmHost": "确认主机身份",
|
||||
"monitor.status.stopped": "未开始",
|
||||
"monitor.status.queued": "排队中",
|
||||
"monitor.status.connecting": "连接中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登录",
|
||||
"monitor.status.error": "采集失败",
|
||||
"monitor.status.unsupported": "不支持",
|
||||
"monitor.status.disconnected": "已断开",
|
||||
"monitor.status.stale": "读数过期",
|
||||
"monitor.status.missing": "主机已删除",
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
'history.action.delete': '从历史记录中删除',
|
||||
'history.action.clearAll': '清空全部历史',
|
||||
'history.clearAll.dialog.title': '清空全部命令历史?',
|
||||
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -399,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.sidePanel.pane.browser': '浏览器',
|
||||
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
@@ -594,6 +603,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
|
||||
@@ -390,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': '未安装连接提供程序',
|
||||
@@ -453,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.browser.title': '浏览器',
|
||||
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
|
||||
'terminal.browser.open': '打开浏览器',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -702,8 +720,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '连接中...',
|
||||
'cloudSync.provider.disconnect': '断开连接',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '断开连接',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
|
||||
'cloudSync.provider.s3': 'S3 兼容存储',
|
||||
@@ -773,6 +794,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
|
||||
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 对话记录',
|
||||
'cloudSync.aiSessions.desc': '把 AI 对话记录纳入云同步。多设备的对话会按追加方式合并,附件内容仍保留在本机。',
|
||||
'cloudSync.aiSessions.compatibility': '旧版本可能覆盖对话记录,请先更新所有参与同步的设备。',
|
||||
'cloudSync.aiSessions.enableFailed': '无法更改聊天同步设置,请检查云端连接和本地可用空间,然后重试。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智能合并(不传播删除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合并两边的变化,且单侧删除不会同步到其它设备——只有所有设备都删掉才会真正消失。恢复备份或多台机器同步时推荐使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
|
||||
'cloudSync.strategy.preferCloud': '云端优先',
|
||||
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
@@ -909,6 +936,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分组设置',
|
||||
'cloudSync.conflict.entity.settings': '设置',
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhTWMonitorMessages } from './zh-TW/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhTWCoreMessages } from './zh-TW/core';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhTW: Messages = {
|
||||
...zhTWMonitorMessages,
|
||||
...zhTWCoreMessages,
|
||||
...zhTWVaultMessages,
|
||||
...zhTWTerminalMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
@@ -503,6 +504,10 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.pinModel': '釘選模型',
|
||||
'ai.chat.unpinModel': '取消釘選',
|
||||
'ai.chat.loadingModels': '正在載入模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '即時目錄',
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
@@ -512,13 +517,21 @@ 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.forkFromHere': '從此處分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
|
||||
@@ -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': '匯出',
|
||||
@@ -767,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
"monitor.password": "密碼(僅本次使用)",
|
||||
"monitor.status.limit": "同時最多監控 256 台不同主機。",
|
||||
"sync.entityType.monitorPanels": "監控面板",
|
||||
"monitor.title": "監控",
|
||||
"monitor.create": "新增面板",
|
||||
"monitor.createFirst": "建立第一個監控面板",
|
||||
"monitor.empty": "一眼查看多台主機",
|
||||
"monitor.emptyHint": "跨群組選擇現有主機,準備好後再開始監控。",
|
||||
"monitor.choosePanel": "選擇面板",
|
||||
"monitor.edit": "編輯面板",
|
||||
"monitor.copy": "複製面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "刪除面板",
|
||||
"monitor.deleteHint":
|
||||
"刪除「{name}」?此面板將停止監控,現有主機和其他面板會保留。",
|
||||
"monitor.start": "開始監控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分鐘",
|
||||
"monitor.sort": "主機排序",
|
||||
"monitor.sort.manual": "儲存的順序",
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核心",
|
||||
"monitor.memory": "記憶體",
|
||||
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暫無容量讀數",
|
||||
"monitor.rootDisk": "根分割區",
|
||||
"monitor.network": "網路",
|
||||
"monitor.download": "下載",
|
||||
"monitor.upload": "上傳",
|
||||
"monitor.updated": "更新時間",
|
||||
"monitor.updatedAt": "更新於 {time}",
|
||||
"monitor.noSamples": "等待第一次讀數",
|
||||
"monitor.cpuTrend": "CPU 趨勢",
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
"monitor.selectionHint": "可跨群組選擇。開始後會明確標示不支援的連線。",
|
||||
"monitor.defaultView": "預設檢視",
|
||||
"monitor.saveHint": "儲存僅保留設定,不會連線。複製的面板也不會自動開始。",
|
||||
"monitor.nameRequired": "請輸入面板名稱。",
|
||||
"monitor.hostRequired": "請至少選擇一台主機。",
|
||||
"monitor.duplicateName": "已有同名面板,請換個名稱。",
|
||||
"monitor.saveFailed": "面板無法儲存,請重試。",
|
||||
"monitor.deleted": "此面板已在其他視窗刪除。請關閉編輯後新增面板。",
|
||||
"monitor.moveUp": "主機上移",
|
||||
"monitor.removeHost": "從面板移除",
|
||||
"monitor.missingHost": "已刪除的主機",
|
||||
"monitor.observed": "本次已觀察 {count} 分鐘",
|
||||
"monitor.disks": "磁碟分割區",
|
||||
"monitor.system": "系統資訊",
|
||||
"monitor.loginHint":
|
||||
"請檢查主機的登入資訊或連線設定後重試。身分確認與登入提示會逐台處理。",
|
||||
"monitor.retry": "重試連線",
|
||||
"monitor.unsupportedHint":
|
||||
"監控需要支援額外命令通道的 Linux 或 macOS SSH 連線。",
|
||||
"monitor.openTerminal": "開啟終端機",
|
||||
"monitor.confirmHost": "確認主機身分",
|
||||
"monitor.status.stopped": "未開始",
|
||||
"monitor.status.queued": "排程中",
|
||||
"monitor.status.connecting": "連線中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登入",
|
||||
"monitor.status.error": "採集失敗",
|
||||
"monitor.status.unsupported": "不支援",
|
||||
"monitor.status.disconnected": "已中斷",
|
||||
"monitor.status.stale": "讀數過期",
|
||||
"monitor.status.missing": "主機已刪除",
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'history.action.run': '在終端執行',
|
||||
'history.action.saveAsSnippet': '儲存為程式碼片段',
|
||||
'history.action.delete': '從歷史記錄中刪除',
|
||||
'history.action.clearAll': '清空全部歷史',
|
||||
'history.clearAll.dialog.title': '清空全部指令歷史?',
|
||||
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '複製檔案路徑',
|
||||
'sftp.context.openWith': '開啟方式...',
|
||||
@@ -399,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
|
||||
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
|
||||
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
|
||||
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
|
||||
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
|
||||
@@ -594,6 +603,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新增工作區',
|
||||
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
|
||||
@@ -390,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': '尚未安裝連線提供者',
|
||||
@@ -453,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '開啟 SFTP',
|
||||
'terminal.browser.title': '瀏覽器',
|
||||
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
|
||||
'terminal.browser.open': '開啟瀏覽器',
|
||||
'terminal.toolbar.availableAfterConnect': '連線後可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -702,8 +720,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '連線中...',
|
||||
'cloudSync.provider.disconnect': '中斷連線',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
|
||||
'cloudSync.provider.s3': 'S3 相容儲存',
|
||||
@@ -773,6 +794,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
|
||||
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 對話記錄',
|
||||
'cloudSync.aiSessions.desc': '把 AI 對話記錄納入雲同步。多裝置的對話會按追加方式合併,附件內容仍保留在本機。',
|
||||
'cloudSync.aiSessions.compatibility': '舊版本可能覆寫對話記錄,請先更新所有參與同步的裝置。',
|
||||
'cloudSync.aiSessions.enableFailed': '無法變更聊天同步設定,請檢查雲端連線與本機可用空間,然後重試。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智慧合併(不傳播刪除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合併兩邊的變化,且單側刪除不會同步到其它裝置——只有所有裝置都刪掉才會真正消失。還原備份或多台機器同步時建議使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
|
||||
'cloudSync.strategy.preferCloud': '雲端優先',
|
||||
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
@@ -909,6 +936,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '群組',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
|
||||
'cloudSync.conflict.entity.groupConfigs': '群組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { MonitorPanel } from "../domain/monitorPanels";
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
const {
|
||||
buildCloudSyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
applyLocalVaultPayload,
|
||||
} = await import("./syncPayload");
|
||||
const {
|
||||
writeMonitorPanels,
|
||||
readMonitorPanels,
|
||||
saveMonitorPanel,
|
||||
deleteMonitorPanel,
|
||||
} = await import("./state/monitorPanelsStorage");
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
const vault = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
};
|
||||
const opts = { collectPluginSidecars: () => undefined };
|
||||
const importers = { importVaultData: () => {} };
|
||||
test("real builders and importers round-trip panel configuration through cloud and backups without runtime state", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([
|
||||
{
|
||||
...panel,
|
||||
stats: { cpu: 42 },
|
||||
running: true,
|
||||
points: [1],
|
||||
error: "never-save",
|
||||
},
|
||||
]);
|
||||
const cloud = await buildCloudSyncPayload(vault, [], opts);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.deepEqual(cloud.monitorPanels, [panel]);
|
||||
assert.deepEqual(backup.monitorPanels, [panel]);
|
||||
assert.ok(!JSON.stringify(cloud).includes("never-save"));
|
||||
writeMonitorPanels([]);
|
||||
await applySyncPayload(cloud, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
const renamed = {
|
||||
...panel,
|
||||
name: "Changed",
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
};
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [renamed] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
const legacy = { ...cloud };
|
||||
delete legacy.monitorPanels;
|
||||
await applySyncPayload(legacy, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
test("serialized window mutations rebase stale fields and do not resurrect a deletion", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
await Promise.all([
|
||||
saveMonitorPanel(panel, { ...panel, name: "Rename" }),
|
||||
saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" }),
|
||||
]);
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "Rename", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
await deleteMonitorPanel("p");
|
||||
await assert.rejects(
|
||||
saveMonitorPanel(panel, { ...panel, name: "Stale" }),
|
||||
/deleted/,
|
||||
);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
|
||||
test("cloud apply preserves panel edits committed after preparation", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
const { prepareSyncPayloadApply } = await import("./syncPayload");
|
||||
const prepared = await prepareSyncPayloadApply(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [{ ...panel, name: "From cloud" }],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
await saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" });
|
||||
await prepared();
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "From cloud", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("cloud and backup imports preserve both independently created same-name panels", async () => {
|
||||
store.clear();
|
||||
const original = { ...panel, id: "a", name: "Fleet" };
|
||||
const other = { ...panel, id: "b", name: "FLEET" };
|
||||
writeMonitorPanels([original]);
|
||||
await applySyncPayload(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [other, original],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readMonitorPanels().map((p) => [p.id, p.name]),
|
||||
[
|
||||
["a", "Fleet"],
|
||||
["b", "FLEET (2)"],
|
||||
],
|
||||
);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.equal(readMonitorPanels().length, 2);
|
||||
assert.equal(
|
||||
new Set(readMonitorPanels().map((p) => p.name.toLowerCase())).size,
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
@@ -47,12 +48,10 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<void> {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
}
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
}
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
@@ -195,12 +194,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 +245,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 +266,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 +302,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 +353,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 +369,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 +387,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,11 +412,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 +427,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] = {
|
||||
@@ -421,6 +470,11 @@ export function serializeSessionsForStorage(
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
|
||||
type StorageListener = (event: StorageEventLike) => void;
|
||||
|
||||
type DeferredRead = { resolve: (value: string) => void };
|
||||
|
||||
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
||||
|
||||
function installLocalStorage(): Map<string, string> {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store registers its cross-window `storage` listener at module load, so
|
||||
* the DOM globals must exist before the dynamic imports below.
|
||||
*/
|
||||
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
|
||||
const listeners = new Set<StorageListener>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
addEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.delete(listener);
|
||||
},
|
||||
netcatty: undefined as unknown,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return (key: string, newValue: string | null, oldValue: string | null = null) => {
|
||||
for (const listener of listeners) listener({ key, newValue, oldValue });
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const storage = installLocalStorage();
|
||||
const dispatchStorageEvent = installWindow();
|
||||
|
||||
const {
|
||||
clearHistory,
|
||||
flushCommandHistoryStore,
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
queryHistory,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
const { seedLocalShellHistoryFromHistfiles } = await import(
|
||||
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
|
||||
);
|
||||
|
||||
function readPersistedCommands(): string[] {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
|
||||
return parsed.entries.map((entry) => entry.command);
|
||||
}
|
||||
|
||||
function readPersistedGeneration(): number {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return 0;
|
||||
const parsed = JSON.parse(raw) as { clearGeneration?: number };
|
||||
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
|
||||
}
|
||||
|
||||
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
|
||||
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
|
||||
|
||||
// No autocomplete query has loaded this renderer's command-history store.
|
||||
const recorded = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: 3,
|
||||
});
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
|
||||
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
|
||||
});
|
||||
|
||||
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
|
||||
clearHistory();
|
||||
recordCommand("old-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
recordCommand("pending-before-clear", "host-1");
|
||||
|
||||
// localStorage is already updated in the peer, but this renderer has not yet
|
||||
// processed its queued storage event when the pending save runs.
|
||||
const generation = readPersistedGeneration() + 1;
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
|
||||
entries: [], version: 1, clearGeneration: generation,
|
||||
}));
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
assert.equal(readPersistedGeneration(), generation);
|
||||
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
|
||||
|
||||
recordCommand("new-after-clear", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
|
||||
});
|
||||
|
||||
test("an ordinary peer write does not cancel a locally pending command", async () => {
|
||||
clearHistory();
|
||||
recordCommand("local-pending-command", "host-1");
|
||||
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
|
||||
const ordinary = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.ok(readPersistedCommands().includes("local-pending-command"));
|
||||
});
|
||||
|
||||
test("an external clear invalidates the cached history and cannot be resurrected", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("stale-command --flag", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
|
||||
|
||||
// Simulate another renderer (main window or a detached terminal popup)
|
||||
// clearing the shared localStorage entry.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
// The stale module-local cache must be dropped immediately.
|
||||
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
|
||||
|
||||
// A later recordCommand must persist only the new command, never the stale
|
||||
// snapshot it still held in memory.
|
||||
recordCommand("fresh-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
|
||||
});
|
||||
|
||||
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("pending-command", "host-1");
|
||||
// No flush: the debounced write is still scheduled.
|
||||
clearHistory();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
|
||||
// Wait past the debounce window to prove the cancelled write cannot restore
|
||||
// the entries.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
});
|
||||
|
||||
test("a local clear bumps the generation and notifies subscribers", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const before = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
recordCommand("notify-local", "host-1");
|
||||
clearHistory();
|
||||
|
||||
assert.equal(getCommandHistoryClearGeneration(), before + 1);
|
||||
assert.equal(readPersistedGeneration(), before + 1);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
|
||||
test("an external clear notifies subscribers, while an external record does not", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const baseline = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// A normal record from another renderer keeps the generation: no clear event.
|
||||
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
assert.equal(notified, 0);
|
||||
|
||||
// A clear from another renderer bumps the generation: subscribers fire.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
assert.equal(notified, 1);
|
||||
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("a histfile import that started before a clear is discarded", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const hostId = `local-seed-clear-${Date.now()}`;
|
||||
const deferredReads: DeferredRead[] = [];
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
deferredReads.push({ resolve });
|
||||
}),
|
||||
};
|
||||
|
||||
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
// Let the seeder resolve getHomeDir and reach the histfile reads.
|
||||
await waitFor(() => deferredReads.length === 4);
|
||||
|
||||
// The user clears history while the bridge reads are still pending.
|
||||
clearHistory();
|
||||
|
||||
for (const deferred of deferredReads) {
|
||||
deferred.resolve(": 1700000000:0;echo seeded\n");
|
||||
}
|
||||
|
||||
assert.equal(await seedPromise, 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
|
||||
// The host is marked seeded so a later open cannot re-import the deleted
|
||||
// commands.
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
|
||||
storage.clear();
|
||||
assert.equal(hasCommandHistoryBeenCleared(), false);
|
||||
|
||||
markCommandHistoryCleared();
|
||||
|
||||
assert.equal(hasCommandHistoryBeenCleared(), true);
|
||||
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
|
||||
});
|
||||
|
||||
test("a first-time histfile import is skipped once history has been cleared", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
markCommandHistoryCleared();
|
||||
|
||||
const hostId = `local-seed-tombstone-${Date.now()}`;
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
|
||||
};
|
||||
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("an external clear also drops in-memory compose-bar recall", async () => {
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
composeBar.pruneComposeBarHistory([]);
|
||||
composeBar.recordComposeBarHistory("session-1", "echo compose");
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// Simulate another renderer clearing command history. The compose-bar store
|
||||
// follows the same clear event, so a detached popup drops its recall too.
|
||||
const cleared = JSON.stringify({
|
||||
entries: [],
|
||||
version: 1,
|
||||
clearGeneration: getCommandHistoryClearGeneration() + 1,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
+154
-12
@@ -2,11 +2,19 @@
|
||||
* Persistent command history store for terminal autocomplete.
|
||||
* Stores commands per host with frequency tracking and timestamp ordering.
|
||||
* Uses localStorageAdapter as the persistence layer (works in renderer process).
|
||||
*
|
||||
* Lives in application state rather than the UI layer so the vault hook and the
|
||||
* terminal UI depend on one lower-layer API, and so every renderer (main window
|
||||
* and detached terminal popups) shares the same clear semantics.
|
||||
*/
|
||||
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
const STORAGE_KEY = "netcatty:commandHistory";
|
||||
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
||||
const MAX_ENTRIES = 10000;
|
||||
const MAX_ENTRIES_PER_HOST = 5000;
|
||||
|
||||
@@ -26,9 +34,21 @@ export interface HistoryEntry {
|
||||
interface HistoryStore {
|
||||
entries: HistoryEntry[];
|
||||
version: number;
|
||||
/**
|
||||
* Monotonic counter bumped by every global clear. Other renderers compare it
|
||||
* to detect a clear performed elsewhere, and in-flight histfile imports use
|
||||
* it to discard work that started before the clear.
|
||||
*/
|
||||
clearGeneration?: number;
|
||||
}
|
||||
|
||||
let cachedStore: HistoryStore | null = null;
|
||||
/** Last clear generation this renderer has observed. */
|
||||
let knownClearGeneration: number | null = null;
|
||||
|
||||
function readClearGeneration(store: HistoryStore): number {
|
||||
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
|
||||
}
|
||||
|
||||
function loadStore(): HistoryStore {
|
||||
if (cachedStore) return cachedStore;
|
||||
@@ -36,18 +56,29 @@ function loadStore(): HistoryStore {
|
||||
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (parsed) {
|
||||
cachedStore = parsed;
|
||||
knownClearGeneration = readClearGeneration(parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted data, reset
|
||||
}
|
||||
cachedStore = { entries: [], version: 1 };
|
||||
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
|
||||
knownClearGeneration = 0;
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function persistStoreNow(store: HistoryStore): boolean {
|
||||
// A peer clear is visible in storage before its queued event is handled.
|
||||
// Never flush a cached pre-clear snapshot over that newer empty store.
|
||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||
cachedStore = persisted;
|
||||
knownClearGeneration = readClearGeneration(persisted);
|
||||
notifyCommandHistoryCleared();
|
||||
return true;
|
||||
}
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
if (ok) return true;
|
||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||
@@ -67,6 +98,13 @@ function saveStore(store: HistoryStore): void {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function cancelPendingSave(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending debounced history write immediately.
|
||||
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
|
||||
@@ -75,13 +113,112 @@ function saveStore(store: HistoryStore): void {
|
||||
*/
|
||||
export function flushCommandHistoryStore(): boolean {
|
||||
if (!cachedStore) return true;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
cancelPendingSave();
|
||||
return persistStoreNow(cachedStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a write performed by another renderer (main window or a detached
|
||||
* terminal popup).
|
||||
*
|
||||
* A bumped clear generation drops the cache and pending save, then notifies
|
||||
* live autocomplete controllers. Ordinary peer writes must not cancel a local
|
||||
* command that is still waiting for its debounced save.
|
||||
*/
|
||||
function applyExternalCommandHistoryWrite(
|
||||
rawNewValue: string | null,
|
||||
rawOldValue: string | null,
|
||||
): void {
|
||||
let incomingGeneration: number | null = null;
|
||||
if (rawNewValue) {
|
||||
try {
|
||||
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
||||
} catch {
|
||||
incomingGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
let previousGeneration = knownClearGeneration;
|
||||
if (previousGeneration === null) {
|
||||
// A fresh popup can have compose recall before its first autocomplete query.
|
||||
// The event's old value is its baseline, even after an earlier global clear.
|
||||
try {
|
||||
previousGeneration = rawOldValue
|
||||
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
||||
: 0;
|
||||
} catch {
|
||||
previousGeneration = 0;
|
||||
}
|
||||
}
|
||||
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
||||
cancelPendingSave();
|
||||
cachedStore = null;
|
||||
knownClearGeneration = incomingGeneration;
|
||||
notifyCommandHistoryCleared();
|
||||
} else {
|
||||
if (!saveTimer) cachedStore = null;
|
||||
if (incomingGeneration !== null && knownClearGeneration === null) {
|
||||
knownClearGeneration = incomingGeneration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The native `storage` event fires in *other* same-origin windows (main window
|
||||
// and detached terminal popups) when a key changes there.
|
||||
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current clear generation. Callers capture this before async work and compare
|
||||
* afterwards to detect a clear that happened while they were waiting.
|
||||
*/
|
||||
export function getCommandHistoryClearGeneration(): number {
|
||||
if (knownClearGeneration === null) loadStore();
|
||||
return knownClearGeneration ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the user explicitly cleared all command history.
|
||||
*
|
||||
* Histfile seeding honors this tombstone: a clear is a user decision to delete
|
||||
* locally recorded history, so a later first-time import must not restore the
|
||||
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
|
||||
* clear that happened before the import started).
|
||||
*/
|
||||
export function markCommandHistoryCleared(): void {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
|
||||
}
|
||||
|
||||
/** True once the user has cleared all command history at least once. */
|
||||
export function hasCommandHistoryBeenCleared(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
|
||||
}
|
||||
|
||||
type CommandHistoryClearedListener = () => void;
|
||||
const clearedListeners = new Set<CommandHistoryClearedListener>();
|
||||
|
||||
/**
|
||||
* Subscribe to command-history clears, including clears performed in another
|
||||
* renderer. Live autocomplete controllers use this to drop visible suggestions
|
||||
* and ghost text and abort in-flight completions, so a stale row cannot be
|
||||
* accepted and recorded after the history was cleared.
|
||||
*/
|
||||
export function subscribeCommandHistoryCleared(
|
||||
listener: CommandHistoryClearedListener,
|
||||
): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => {
|
||||
clearedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyCommandHistoryCleared(): void {
|
||||
for (const listener of [...clearedListeners]) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a command execution. Updates frequency if the command already exists
|
||||
* for this host, otherwise creates a new entry.
|
||||
@@ -149,10 +286,7 @@ export function removeCommandHistoryEntry(command: string, hostId: string): bool
|
||||
if (nextEntries.length === store.entries.length) return false;
|
||||
|
||||
store.entries = nextEntries;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
cancelPendingSave();
|
||||
return persistStoreNow(store);
|
||||
}
|
||||
|
||||
@@ -427,6 +561,8 @@ function fuzzyScore(query: string, target: string): number {
|
||||
|
||||
/**
|
||||
* Clear all history for a specific host, or all history if no hostId given.
|
||||
* A global clear also bumps the shared clear generation so other renderers and
|
||||
* in-flight histfile imports can detect it.
|
||||
*/
|
||||
export function clearHistory(hostId?: string): void {
|
||||
const store = loadStore();
|
||||
@@ -434,6 +570,12 @@ export function clearHistory(hostId?: string): void {
|
||||
store.entries = store.entries.filter((e) => e.hostId !== hostId);
|
||||
} else {
|
||||
store.entries = [];
|
||||
store.clearGeneration = readClearGeneration(store) + 1;
|
||||
}
|
||||
saveStore(store);
|
||||
// Destructive clears must be durable: cancel any pending debounced write and
|
||||
// persist synchronously so a later stale flush cannot restore the entries.
|
||||
cancelPendingSave();
|
||||
persistStoreNow(store);
|
||||
knownClearGeneration = readClearGeneration(store);
|
||||
if (!hostId) notifyCommandHistoryCleared();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } from './composeBarHistoryStore';
|
||||
|
||||
test('compose history is isolated per terminal and removed when its session closes', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
@@ -40,3 +40,24 @@ test('failed submissions unblock later successes in submission order', async ()
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'echo a');
|
||||
recordComposeBarHistory('b', 'echo b');
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
clearComposeBarHistory();
|
||||
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
assert.deepEqual(getComposeBarHistory('b'), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearComposeBarHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
|
||||
|
||||
// Session-only memory: survives closing the bar and moving a terminal into a
|
||||
// workspace, but is discarded when that terminal session is removed.
|
||||
type PendingSend = { settled: boolean; command?: string };
|
||||
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
|
||||
|
||||
type Listener = () => void;
|
||||
const clearedListeners = new Set<Listener>();
|
||||
|
||||
export function getComposeBarHistory(sessionId: string): readonly string[] {
|
||||
return histories.get(sessionId)?.entries ?? [];
|
||||
}
|
||||
@@ -44,3 +48,36 @@ export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
|
||||
if (!active.has(id)) histories.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to full compose-bar history clears. A live compose bar uses this to
|
||||
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
|
||||
*/
|
||||
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => {
|
||||
clearedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-memory compose-bar history for every session.
|
||||
*
|
||||
* This store is session-only (never persisted), so the "clear all command
|
||||
* history" action must clear it explicitly. Otherwise a command sent through
|
||||
* the compose bar stays recallable, and resending it would re-record it into
|
||||
* shell history and autocomplete after the user deleted it.
|
||||
*/
|
||||
export function clearComposeBarHistory(): void {
|
||||
histories.clear();
|
||||
// Always notify: a live compose bar can still hold a cached walk even when
|
||||
// the map is already empty (for example after its session was pruned).
|
||||
for (const listener of [...clearedListeners]) listener();
|
||||
}
|
||||
|
||||
// The compose bar holds the most recent commands sent through it, so clearing
|
||||
// command history must drop it too. Subscribing to the command-history clear
|
||||
// event covers every renderer: a detached terminal popup renders its own
|
||||
// TerminalComposeBar and learns about a main-window clear through the shared
|
||||
// clear generation, while the main window is notified synchronously.
|
||||
subscribeCommandHistoryCleared(clearComposeBarHistory);
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
|
||||
|
||||
test('detached terminal requests reach the main window instead of being dropped', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const forwarded: HostBrowserPanelRequest[] = [];
|
||||
const delivered: HostBrowserPanelRequest[] = [];
|
||||
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
|
||||
let removed = false;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
|
||||
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
|
||||
nativeListener = listener;
|
||||
return () => { removed = true; nativeListener = undefined; };
|
||||
},
|
||||
} } });
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
|
||||
requestHostBrowserPanel(request);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(forwarded, [request]);
|
||||
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
|
||||
assert.ok(nativeListener);
|
||||
nativeListener(request);
|
||||
assert.deepEqual(delivered, [request]);
|
||||
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
|
||||
unsubscribe(); unsubscribe = undefined;
|
||||
assert.equal(removed, true);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
|
||||
type Listener = (request: HostBrowserPanelRequest) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: HostBrowserPanelRequest | null = null;
|
||||
let unsubscribeNative: (() => void) | undefined;
|
||||
|
||||
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
|
||||
if (!listeners.size) {
|
||||
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
|
||||
if (forward) {
|
||||
void forward(request).catch(() => { pending = request; });
|
||||
} else pending = request;
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) listener(request);
|
||||
}
|
||||
|
||||
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
|
||||
for (const current of listeners) current(request);
|
||||
});
|
||||
if (pending) {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
listener(request);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mergeSyncPayloads } from "../../domain/syncMerge";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
normalizeMonitorPanels,
|
||||
rebaseMonitorPanel,
|
||||
type MonitorPanel,
|
||||
} from "../../domain/monitorPanels";
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from "../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
localStorageAdapter,
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { withVaultImportLock } from "./vaultManagedImportLock";
|
||||
|
||||
export function readMonitorPanels(): MonitorPanel[] {
|
||||
return normalizeMonitorPanels(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_PANELS),
|
||||
);
|
||||
}
|
||||
export function writeMonitorPanels(value: unknown): void {
|
||||
if (
|
||||
!localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_PANELS,
|
||||
normalizeMonitorPanels(value),
|
||||
)
|
||||
)
|
||||
throw new Error("monitor.saveFailed");
|
||||
}
|
||||
export async function saveMonitorPanel(
|
||||
base: MonitorPanel | null,
|
||||
next: MonitorPanel,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(rebaseMonitorPanel(readMonitorPanels(), base, next)),
|
||||
);
|
||||
}
|
||||
export async function deleteMonitorPanel(id: string): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(readMonitorPanels().filter((p) => p.id !== id)),
|
||||
);
|
||||
}
|
||||
/** Rebase a prepared cloud snapshot against edits committed while it was applying. */
|
||||
export async function applyMonitorPanels(
|
||||
value: unknown,
|
||||
base: MonitorPanel[],
|
||||
replace = false,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const remote = normalizeMonitorPanels(value);
|
||||
if (replace) {
|
||||
writeMonitorPanels(remote);
|
||||
return;
|
||||
}
|
||||
const payload = (panels: MonitorPanel[]) => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 0,
|
||||
monitorPanels: panels,
|
||||
});
|
||||
writeMonitorPanels(
|
||||
mergeSyncPayloads(
|
||||
payload(base),
|
||||
payload(readMonitorPanels()),
|
||||
payload(remote),
|
||||
).payload.monitorPanels ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
let cachedRaw: string | null | undefined;
|
||||
let cached: MonitorPanel[] = [];
|
||||
function getSnapshot() {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_MONITOR_PANELS);
|
||||
if (raw !== cachedRaw) {
|
||||
cachedRaw = raw;
|
||||
cached = readMonitorPanels();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
function subscribe(listener: () => void) {
|
||||
const handler = (event: Event) => {
|
||||
const key =
|
||||
event instanceof StorageEvent
|
||||
? event.key
|
||||
: (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (!key || key === STORAGE_KEY_MONITOR_PANELS) listener();
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
};
|
||||
}
|
||||
export function useMonitorPanels(): MonitorPanel[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
|
||||
const empty: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
running: [],
|
||||
count: 0,
|
||||
hostCount: 0,
|
||||
epoch: 0,
|
||||
runtimeId: "",
|
||||
takenAt: 0,
|
||||
activeSessions: [],
|
||||
startedAt: {},
|
||||
hosts: {},
|
||||
};
|
||||
let snapshot = empty;
|
||||
const listeners = new Set<() => void>();
|
||||
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
publishMonitorSnapshot({ ...empty, unavailable: true });
|
||||
}
|
||||
export function useMonitorSnapshot() {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
() => snapshot,
|
||||
() => empty,
|
||||
);
|
||||
}
|
||||
export const monitorActions: {
|
||||
start: (id: string) => void;
|
||||
stop: (id: string) => void;
|
||||
stopAll: () => void;
|
||||
retry: (
|
||||
id: string,
|
||||
credentials?: { username: string; password: string },
|
||||
) => void;
|
||||
} = {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
stopAll: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
@@ -8,10 +8,20 @@ import {
|
||||
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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,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", () => {
|
||||
@@ -133,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
@@ -150,3 +179,25 @@ test("exit messages without a reported code stay plain instead of showing a sent
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,10 @@ 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".
|
||||
@@ -16,6 +19,13 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
@@ -34,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" };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -171,3 +171,16 @@ test("host session factories snapshot plugin connection configuration", () => {
|
||||
assert.deepEqual(workspace.pluginConnection, pluginConnection);
|
||||
assert.notEqual(workspace.pluginConnection, pluginConnection);
|
||||
});
|
||||
|
||||
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
|
||||
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
|
||||
const source = createHostTerminalSession("original-bash", original);
|
||||
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
|
||||
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
|
||||
assert.equal(copied.localShell, "/bin/bash");
|
||||
assert.equal(copied.localShellName, "");
|
||||
assert.equal(copied.localShellIcon, "");
|
||||
const named = createHostTerminalSession("named-zsh", edited);
|
||||
assert.equal(named.localShellName, "Zsh");
|
||||
assert.equal(named.localShellIcon, "zsh");
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,7 +148,8 @@ test("session peer windows do not run main-window startup effects", () => {
|
||||
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
|
||||
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
// Detached windows share opacity, while general settings broadcasts stay off.
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
|
||||
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
|
||||
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -201,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) => {
|
||||
@@ -222,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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,55 @@ import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
let streamStarts = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({ type: "file", size: 3626, lastModified: 1758000001000 }),
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async () => { streamStarts += 1; return {}; },
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = `failed-${direction}-connection-3698`;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(direction === "download" ? side : side === "left" ? "right" : "left"),
|
||||
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 () => [],
|
||||
acquireTransferSession: async () => { throw new Error("SFTP channel open timed out after 10000ms"); },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "failed");
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel open timed out after 10000ms");
|
||||
assert.ok(task?.endTime);
|
||||
assert.equal(streamStarts, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -721,7 +721,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
|
||||
|
||||
@@ -1137,7 +1136,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
} finally {
|
||||
// Walk registry cleanup is owned by transferRuntime.runWalk.
|
||||
// Drop ephemeral pool holds opened when browse was soft-closed.
|
||||
|
||||
@@ -5,6 +5,8 @@ import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
@@ -20,6 +22,60 @@ const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('workspace browser follows focus without replacing the earlier terminal context', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const browserSlot = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'SidePanelBrowserSlot');
|
||||
assert.ok(browserSlot);
|
||||
const slotCode = ts.transpile(browserSlot.getText(ast), { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React });
|
||||
const Slot = vm.runInNewContext(`${slotCode}; SidePanelBrowserSlot`, {
|
||||
React, useRef, useSidePanelLiveSnapshotForTab: useSnapshot, resolveSystemSidebarSession, collectSessionIds,
|
||||
sidePanelHiddenPanelClassName: (hidden: boolean) => hidden ? 'hidden' : 'visible',
|
||||
HostBrowserSidePanel: (props: object) => React.createElement('div', { ...props, 'data-browser-context': true }),
|
||||
}) as React.ComponentType<{ tabId: string; ctx: object; isVisible: boolean }>;
|
||||
const sessionA = { id: 'session-a', hostId: 'host-a', protocol: 'ssh' };
|
||||
const sessionB = { id: 'session-b', hostId: 'host-b', protocol: 'ssh' };
|
||||
const workspace = { id: 'workspace', focusedSessionId: sessionA.id, root: {
|
||||
id: 'split', type: 'split', direction: 'vertical', sizes: [0.5, 0.5], children: [
|
||||
{ id: 'a', type: 'pane', sessionId: sessionA.id }, { id: 'b', type: 'pane', sessionId: sessionB.id },
|
||||
],
|
||||
} } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const ctx = { sessions: [sessionA, sessionB], workspaceById: new Map([['workspace', workspace]]),
|
||||
sessionHostsMap: new Map([['session-a', { id: 'host-a', hostname: 'a' }], ['session-b', { id: 'host-b', hostname: 'b' }]]),
|
||||
sftpAuthHostBySessionId: new Map(), effectiveHosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, t: (key: string) => key };
|
||||
const publish = (session: typeof sessionA) => sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
activeWorkspace: workspace, focusedSessionId: session.id,
|
||||
activeTerminalSessionForSystem: session as SidePanelLiveSnapshot['activeTerminalSessionForSystem'] });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
publish(sessionA);
|
||||
await act(async () => { renderer = create(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: true })); });
|
||||
const contexts = () => renderer!.root.findAll((node) => node.type === 'div' && node.props['data-browser-context'] === true);
|
||||
const first = contexts()[0];
|
||||
assert.equal(first.props.host.id, 'host-a');
|
||||
await act(async () => publish(sessionB));
|
||||
assert.equal(contexts().length, 2);
|
||||
assert.equal(contexts()[0], first, 'the first terminal keeps the same browser mount');
|
||||
assert.equal(first.props.isVisible, false);
|
||||
assert.equal(contexts()[1].props.host.id, 'host-b');
|
||||
assert.equal(contexts()[1].props.isVisible, true);
|
||||
await act(async () => publish(sessionA));
|
||||
assert.equal(first.props.isVisible, true);
|
||||
assert.equal(contexts()[1].props.isVisible, false);
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.ok(contexts().every((context) => !context.props.isVisible), 'hidden tabs hide every native browser context');
|
||||
ctx.workspaceById.set('workspace', { ...workspace, focusedSessionId: sessionB.id,
|
||||
root: { id: 'b', type: 'pane', sessionId: sessionB.id } });
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.equal(contexts().length, 1, 'a detached session must leave the old workspace even while it still exists globally');
|
||||
assert.equal(contexts()[0].props.host.id, 'host-b', 'the remaining workspace browser stays mounted');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -266,9 +266,9 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
|
||||
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
// Window chrome also applies in detached sessions, independently of the
|
||||
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
@@ -294,7 +294,6 @@ export function useSystemSettingsEffects({
|
||||
if (!decision.shouldBroadcast) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
|
||||
}, [
|
||||
enabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
notifySettingsChanged,
|
||||
|
||||
@@ -58,6 +58,52 @@ test("normal shell exits do not auto reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "timeout" as const },
|
||||
{ reason: "timeout" as const, exitCode: 0 },
|
||||
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
|
||||
{ reason: "signaled" as const },
|
||||
// A path that still says "closed" but plumbed the exit-signal is the same kick.
|
||||
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
|
||||
{ reason: "closed" as const, exitSignal: " SIGHUP " },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("transport drops still auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "closed" as const },
|
||||
{ reason: "error" as const, error: "connection reset" },
|
||||
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
|
||||
{ reason: "closed" as const, exitSignal: "" },
|
||||
{ reason: "closed" as const, exitSignal: " " },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
|
||||
@@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
return evt.reason !== "exited";
|
||||
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
|
||||
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
|
||||
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
|
||||
// Reconnecting would fight that policy. Transport "error" and bare "closed"
|
||||
// still retry.
|
||||
if (evt.reason === "error") return true;
|
||||
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
|
||||
return false;
|
||||
}
|
||||
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
|
||||
@@ -224,3 +224,31 @@ test("split and copy session clones preserve isolated plugin connection snapshot
|
||||
assert.equal(split.reuseConnectionFromSessionId, undefined);
|
||||
assert.equal(copied.reuseConnectionFromSessionId, undefined);
|
||||
});
|
||||
|
||||
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
|
||||
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
|
||||
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
|
||||
const source = session({ protocol: "local", localShell, shellType });
|
||||
const copied = clone(source, { id: "clone", localShellType: "posix" });
|
||||
assert.equal(copied.localShell, localShell);
|
||||
assert.equal(copied.shellType, shellType);
|
||||
assert.equal(clone({ ...source, shellType: undefined },
|
||||
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
|
||||
}
|
||||
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
|
||||
{ id: "clone-default", localShellType: "posix" });
|
||||
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
|
||||
});
|
||||
}
|
||||
|
||||
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
|
||||
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
|
||||
for (const clone of [
|
||||
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
|
||||
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
|
||||
]) {
|
||||
assert.equal(clone.localShell, "");
|
||||
assert.equal(clone.shellType, "powershell");
|
||||
assert.equal(clone.localStartDir, "~");
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
@@ -44,7 +45,12 @@ function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
return session.protocol === "local" ? localShellType : session.shellType;
|
||||
if (session.protocol !== "local") return session.shellType;
|
||||
// Clones retain an explicit per-session executable, so its shell type must
|
||||
// survive a different global default (e.g. saved CMD with WSL in Settings).
|
||||
return session.localShell !== undefined
|
||||
? (session.shellType ?? classifyLocalShellType(session.localShell))
|
||||
: localShellType;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { stopAgentTurn } from '../../infrastructure/ai/harness/agentStop';
|
||||
import type {
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
@@ -46,6 +47,16 @@ export function isAIChatSessionStreaming(sessionId: string | null | undefined):
|
||||
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
|
||||
}
|
||||
|
||||
/** Use the same stop entry and controllers as the UI before replacing history. */
|
||||
export async function stopAIChatSessionBeforeReplacement(sessionId: string): Promise<void> {
|
||||
const controller = sharedAbortControllers.get(sessionId);
|
||||
if (controller || sharedStreamingSessionIds.has(sessionId)) {
|
||||
await stopAgentTurn({ chatSessionId: sessionId, abortController: controller, bridge: getNetcattyBridge() });
|
||||
}
|
||||
await getAgentRuntime().waitForActiveTurn(sessionId);
|
||||
if (controller && sharedAbortControllers.get(sessionId) === controller) sharedAbortControllers.delete(sessionId);
|
||||
}
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Fork source',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-a' },
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'bad last prompt',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
||||
],
|
||||
},
|
||||
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
};
|
||||
|
||||
type HookCapture = {
|
||||
ai: ReturnType<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function ForkHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
||||
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ForkHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
|
||||
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'running-command' },
|
||||
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.externalSessionId = 'original-provider-thread';
|
||||
source.messages[1].content = `Preview handleId=${handle.id}`;
|
||||
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
|
||||
assert.ok(branch);
|
||||
assert.notEqual(branch!.id, source.id);
|
||||
assert.equal(branch!.externalSessionId, undefined);
|
||||
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
|
||||
assert.match(branch!.messages[1].content, /Preview/);
|
||||
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
|
||||
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
|
||||
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
|
||||
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
|
||||
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
|
||||
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
|
||||
runtime.getSessionStateStore().clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
test('fork rejects an incomplete boundary without adding a chat', async () => {
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].executionStatus = 'failed';
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let result: unknown;
|
||||
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
|
||||
assert.equal(result, null);
|
||||
assert.equal(capture.sessions.length, 1);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
|
||||
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
|
||||
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||
assert.ok(branch);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { publishAISessionsSnapshot } from './aiSessionsStore';
|
||||
import { planSessionFork } from '../../domain/aiSessionFork';
|
||||
import {
|
||||
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
@@ -86,6 +87,11 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
type UndoLastTurnRestoredDraft,
|
||||
} from '../../domain/aiUndoLastTurn';
|
||||
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
@@ -675,6 +681,57 @@ export function useAIState() {
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
// "Fork from here": copy the conversation up to a completed assistant
|
||||
// response into a new session and make it the scope's active chat. The
|
||||
// original session stays untouched in history. Pure boundary validation and
|
||||
// plan building live in domain/aiSessionFork; the fork has no original
|
||||
// external session id or pending-turn state. Per-message replay metadata is
|
||||
// preserved; output handles and running jobs remain owned by the source.
|
||||
// Null is returned when the boundary is not forkable.
|
||||
// `viewingScope` is the panel scope the fork was requested from; the branch
|
||||
// is created there so the fork is active and visible in the very scope that
|
||||
// is viewing it, even when the source session carries a stale scope (e.g. a
|
||||
// terminal chat resumed in a reconnected terminal or a merged workspace).
|
||||
const forkSessionFromMessage = useCallback(async (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<AISession | null> => {
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const plan = planSessionFork(source, messageId);
|
||||
if (!plan.ok) return null;
|
||||
const now = Date.now();
|
||||
const fork: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: plan.title,
|
||||
agentId: source.agentId,
|
||||
scope: {
|
||||
...source.scope,
|
||||
// Host membership stays with the source (the messages were produced
|
||||
// there); the scope the fork belongs to is the viewing scope.
|
||||
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
|
||||
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
|
||||
},
|
||||
messages: plan.messages,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (plan.contextCompaction) {
|
||||
fork.contextCompaction = plan.contextCompaction;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep the source alongside its branch within the existing history limit.
|
||||
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, fork.id);
|
||||
return fork;
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
@@ -697,6 +754,40 @@ export function useAIState() {
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
// Non-destructive "undo last turn": branch the session at the boundary
|
||||
// before the latest user message and keep the original in history intact.
|
||||
// Returns the new branch id plus the undone user message for the composer.
|
||||
const undoLastTurnInSession = useCallback(async (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
} | null> => {
|
||||
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
|
||||
if (!result) return null;
|
||||
|
||||
const branched = result.session;
|
||||
// Like Fork, Undo belongs to the panel viewing the source, which may have
|
||||
// resumed a chat from a closed terminal or inherited it after a merge.
|
||||
if (viewingScope) {
|
||||
branched.scope = { ...branched.scope, ...viewingScope };
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, branched.id);
|
||||
return { sessionId: branched.id, restored: result.restored };
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupDeletedAIChatSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
@@ -1275,6 +1366,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1287,6 +1379,7 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1335,6 +1428,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1347,5 +1441,6 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Undo source',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-a' },
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'bad last prompt',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
||||
],
|
||||
},
|
||||
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
};
|
||||
|
||||
type HookCapture = {
|
||||
ai: ReturnType<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function UndoHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
||||
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
||||
await act(async () => {
|
||||
root.render(React.createElement(UndoHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('undoLastTurnInSession branches and restores the last user turn', async () => {
|
||||
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
|
||||
async function runUndo() {
|
||||
return capture.ai!.undoLastTurnInSession('chat-source');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
result = await runUndo();
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.notEqual(result!.sessionId, 'chat-source');
|
||||
|
||||
const sessions = capture.sessions;
|
||||
const branch = sessions.find((session) => session.id === result!.sessionId);
|
||||
assert.ok(branch);
|
||||
// Branch keeps the conversation up to the boundary before the last turn.
|
||||
assert.deepEqual(
|
||||
branch!.messages.map((message) => message.content),
|
||||
['first prompt', 'first answer'],
|
||||
);
|
||||
// Original session stays intact (non-destructive).
|
||||
const original = sessions.find((session) => session.id === 'chat-source');
|
||||
assert.equal(original?.messages.length, 4);
|
||||
// Branch becomes the active session for the scope.
|
||||
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
|
||||
|
||||
const attachments = result!.restored.attachments as UploadedFile[];
|
||||
assert.equal(result!.restored.text, 'bad last prompt');
|
||||
assert.equal(attachments.length, 1);
|
||||
assert.equal(attachments[0].filename, 'note.txt');
|
||||
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
|
||||
const emptySession: AISession = {
|
||||
...SOURCE_SESSION,
|
||||
id: 'chat-empty',
|
||||
messages: [],
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([emptySession]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: unknown;
|
||||
await act(async () => {
|
||||
result = await capture.ai!.undoLastTurnInSession('chat-empty');
|
||||
});
|
||||
assert.equal(result, null);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undo copies chat only, preserving original outputs and job ownership', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({
|
||||
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
|
||||
});
|
||||
const stateStore = runtime.getSessionStateStore();
|
||||
stateStore.updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'long-running-command' },
|
||||
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].content = `Preview with handleId=${handle.id}`;
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const original = JSON.stringify(source);
|
||||
let result: { sessionId: string } | null = null;
|
||||
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
|
||||
assert.ok(result);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
|
||||
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
|
||||
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
|
||||
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
|
||||
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
|
||||
stateStore.clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
|
||||
const newer = Array.from({ length: newerCount }, (_, index) => ({
|
||||
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
|
||||
}));
|
||||
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
||||
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
||||
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
// Remount the actual hook from the persisted records, as on app reload.
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => reloaded.root.unmount());
|
||||
reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewingScope of [
|
||||
{ type: 'terminal' as const, targetId: 'terminal-b' },
|
||||
{ type: 'workspace' as const, targetId: 'workspace-b' },
|
||||
]) {
|
||||
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
|
||||
const source = {
|
||||
...structuredClone(SOURCE_SESSION),
|
||||
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const sourceBefore = structuredClone(source);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const branch = capture.sessions.find(item => item.id === branchId)!;
|
||||
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
|
||||
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
|
||||
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
|
||||
// The real history predicate must still include the selected branch after
|
||||
// the source terminal no longer belongs to the current workspace.
|
||||
assert.equal(getSessionScopeMatchRank(
|
||||
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
|
||||
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { MonitorPanel } from "../../domain/monitorPanels";
|
||||
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
const events = new EventTarget();
|
||||
for (const key of [
|
||||
"addEventListener",
|
||||
"removeEventListener",
|
||||
"dispatchEvent",
|
||||
] as const) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: events[key].bind(events),
|
||||
});
|
||||
}
|
||||
const windowFixture = Object.assign(events, {
|
||||
localStorage: storage,
|
||||
CustomEvent,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: windowFixture,
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const { useAutoSync } = await import("./useAutoSync");
|
||||
const { getCloudSyncManager } =
|
||||
await import("../../infrastructure/services/CloudSyncManager");
|
||||
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
|
||||
await import("./monitorPanelsStorage");
|
||||
|
||||
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
|
||||
const manager = getCloudSyncManager();
|
||||
const snapshot = {
|
||||
...manager.getState(),
|
||||
securityState: "UNLOCKED" as const,
|
||||
syncState: "IDLE" as const,
|
||||
autoSyncEnabled: true,
|
||||
providers: {
|
||||
...manager.getState().providers,
|
||||
webdav: { provider: "webdav" as const, status: "connected" as const },
|
||||
},
|
||||
};
|
||||
const uploads: SyncPayload[] = [];
|
||||
const originals = {
|
||||
getState: manager.getState,
|
||||
syncAllProviders: manager.syncAllProviders,
|
||||
loadSyncBase: manager.loadSyncBase,
|
||||
inspectProviderRemote: manager.inspectProviderRemote,
|
||||
setPendingLocalSync: manager.setPendingLocalSync,
|
||||
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
||||
};
|
||||
manager.getState = () => snapshot;
|
||||
manager.loadSyncBase = async () => null;
|
||||
manager.inspectProviderRemote = async () => ({
|
||||
remoteFile: null,
|
||||
payload: null,
|
||||
remoteChanged: false,
|
||||
});
|
||||
manager.syncAllProviders = async (payload) => {
|
||||
uploads.push(structuredClone(payload));
|
||||
return new Map<CloudProvider, SyncResult>([
|
||||
["webdav", { provider: "webdav", success: true, action: "upload" }],
|
||||
]);
|
||||
};
|
||||
manager.setPendingLocalSync = () => {};
|
||||
manager.setAvailablePluginSyncProviders = () => {};
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
let api: ReturnType<typeof useAutoSync> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const apply = async () => {};
|
||||
const Probe = () => {
|
||||
api = useAutoSync({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ["fixture"],
|
||||
startupReady: true,
|
||||
onApplyPayload: apply,
|
||||
onApplyConvergentPayload: apply,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const waitForUpload = async (count: number) => {
|
||||
// Flush the adapter's batched event, then observe the actual debounced
|
||||
// upload. Slow CI must not be mistaken for an incomplete collection.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
if (uploads.length === count) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
});
|
||||
} else {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (uploads.length < count && Date.now() < deadline)
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
uploads.length,
|
||||
count,
|
||||
"a panel-only change must alter the auto-sync hash and upload",
|
||||
);
|
||||
};
|
||||
try {
|
||||
writeMonitorPanels([
|
||||
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
|
||||
]);
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
await act(async () => {
|
||||
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
|
||||
});
|
||||
assert.deepEqual(uploads[0].monitorPanels, [panel]);
|
||||
const renamed = { ...panel, name: "Renamed" };
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, renamed);
|
||||
});
|
||||
await waitForUpload(2);
|
||||
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
|
||||
const edited = {
|
||||
...renamed,
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
interval: 30 as const,
|
||||
};
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(renamed, edited);
|
||||
});
|
||||
await waitForUpload(3);
|
||||
assert.deepEqual(uploads[2].monitorPanels, [edited]);
|
||||
await act(async () => {
|
||||
await deleteMonitorPanel(panel.id);
|
||||
});
|
||||
await waitForUpload(4);
|
||||
assert.deepEqual(uploads[3].monitorPanels, []);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(null, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
assert.deepEqual(uploads[4].monitorPanels, [panel]);
|
||||
assert.ok(
|
||||
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
|
||||
);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMonitorPanels } from './monitorPanelsStorage';
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
|
||||
/**
|
||||
* useAutoSync - Auto-sync Hook for Cloud Sync
|
||||
*
|
||||
@@ -21,12 +23,14 @@ import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type Syn
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
|
||||
import {
|
||||
isPreserveDeletionsStrategy,
|
||||
resolveCloudSyncConflictAction,
|
||||
type CloudSyncConflictAction,
|
||||
} from '../../domain/syncStrategy';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectCloudSyncableSettings,
|
||||
collectAiSessionsForSync,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
@@ -38,6 +42,8 @@ import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBri
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
@@ -51,6 +57,7 @@ import {
|
||||
} from './autoSyncRemoteSchedule';
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
import { getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import { collectHostBrowserBookmarks } from '../hostBrowserBookmarks';
|
||||
|
||||
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
|
||||
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
|
||||
@@ -146,6 +153,7 @@ const isRestoreInProgress = (): boolean => {
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
monitorPanels: payload.monitorPanels,
|
||||
hosts: sanitizeHostsForSync(payload.hosts),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
@@ -156,9 +164,11 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
notes: payload.notes,
|
||||
noteGroups: payload.noteGroups,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
browserBookmarks: payload.browserBookmarks,
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
aiSessions: payload.aiSessions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -219,6 +229,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
const [hostBrowserBookmarksVersion, setHostBrowserBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onHostBrowserBookmarksChanged?.(() => {
|
||||
setHostBrowserBookmarksVersion((version) => version + 1);
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handler = () => setBookmarksVersion((v) => v + 1);
|
||||
window.addEventListener('sftp-bookmarks-changed', handler);
|
||||
@@ -243,7 +260,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const bumpIfSyncableSetting = (key: string | null | undefined) => {
|
||||
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
|
||||
if (!key) return;
|
||||
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
|
||||
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -265,6 +286,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
return {
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts: sanitizeHostsForSync(config.hosts),
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
@@ -299,8 +321,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
const base: SyncPayload = {
|
||||
...getSyncSnapshot(),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -781,7 +807,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
|
||||
hasConflict: inspection.remoteChanged,
|
||||
hasConflict: inspection.remoteChanged || isPreserveDeletionsStrategy(state.syncStrategy),
|
||||
hasRemoteFile: Boolean(inspection.remoteFile),
|
||||
});
|
||||
|
||||
@@ -850,7 +876,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// upload and wipe usable cloud passwords. Same for remote-side poison.
|
||||
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
|
||||
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed, {
|
||||
preserveDeletions: isPreserveDeletionsStrategy(state.syncStrategy),
|
||||
});
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
@@ -1112,6 +1140,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
@@ -1129,6 +1158,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
@@ -173,6 +175,9 @@ export interface CloudSyncHook {
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
/** Whether AI chat sessions are included in cloud sync payloads (opt-in). */
|
||||
aiSessionsSyncEnabled: boolean;
|
||||
setAiSessionsSyncEnabled: (enabled: boolean) => Promise<void>;
|
||||
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
|
||||
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
|
||||
|
||||
@@ -249,6 +254,25 @@ const getSnapshot = (): SyncManagerState => {
|
||||
return manager.getState();
|
||||
};
|
||||
|
||||
function readAiSessionsSyncEnabled(): boolean {
|
||||
return localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
}
|
||||
|
||||
function subscribeAiSessionsSyncEnabled(listener: () => void): () => void {
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS || event.key === null) listener();
|
||||
};
|
||||
const local = (event: Event) => {
|
||||
if ((event as CustomEvent<{ key?: string }>).detail?.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) listener();
|
||||
};
|
||||
window.addEventListener('storage', storage);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
|
||||
return () => {
|
||||
window.removeEventListener('storage', storage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
|
||||
};
|
||||
}
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
@@ -724,6 +748,19 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
const aiSessionsSyncEnabled = useSyncExternalStore(
|
||||
subscribeAiSessionsSyncEnabled, readAiSessionsSyncEnabled, readAiSessionsSyncEnabled,
|
||||
);
|
||||
const setAiSessionsSyncEnabled = useCallback(async (enabled: boolean) => {
|
||||
const { setAiSessionsSyncPreference } = await import('../syncPayload');
|
||||
await setAiSessionsSyncPreference(enabled, async () => {
|
||||
const providers = Object.entries(manager.getAllProviders())
|
||||
.filter(([, connection]) => isProviderReadyForSync(connection));
|
||||
const remotes = await Promise.all(providers.map(([provider]) => manager.downloadFromProvider(provider as CloudProvider)));
|
||||
return remotes.filter((remote) => remote !== null).map((remote) => remote.payload);
|
||||
}, manager.getState().syncStrategy === 'smartMergePreserve');
|
||||
}, []);
|
||||
|
||||
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
|
||||
manager.setSyncStrategy(strategy);
|
||||
}, []);
|
||||
@@ -864,6 +901,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
}
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
|
||||
pluginSidecars,
|
||||
aiSessions: (await import('../syncPayload')).collectAiSessionsForSync(),
|
||||
});
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
@@ -956,6 +994,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
aiSessionsSyncEnabled,
|
||||
setAiSessionsSyncEnabled,
|
||||
setConvergentSyncEnabled,
|
||||
refreshConvergentSyncConfig,
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useComposeBarHistory } from './useComposeBarHistory';
|
||||
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
import {
|
||||
clearComposeBarHistory,
|
||||
pruneComposeBarHistory,
|
||||
recordComposeBarHistory,
|
||||
} from './composeBarHistoryStore';
|
||||
|
||||
test('changing workspace focus resets navigation without replacing the draft', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -47,3 +51,31 @@ test('an in-progress history walk stays stable when a late send evicts the oldes
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('clearing history restores the pre-walk draft instead of leaving the recalled command', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'deleted-command');
|
||||
const restoredDrafts: string[] = [];
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness() {
|
||||
history = useComposeBarHistory('a', (draft) => { restoredDrafts.push(draft); });
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
|
||||
// Enter a history walk: the textarea now shows the recalled command while the
|
||||
// hook holds the pre-walk draft.
|
||||
assert.equal(history.navigate('my unsent draft', 'up'), 'deleted-command');
|
||||
|
||||
clearComposeBarHistory();
|
||||
|
||||
// The pre-walk draft is put back, so the deleted command is no longer in the
|
||||
// textarea ready for Enter.
|
||||
assert.deepEqual(restoredDrafts, ['my unsent draft']);
|
||||
assert.equal(history.navigate('my unsent draft', 'up'), undefined);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
|
||||
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
|
||||
import {
|
||||
getComposeBarHistory,
|
||||
createComposeBarHistoryRecorder,
|
||||
subscribeComposeBarHistoryCleared,
|
||||
} from './composeBarHistoryStore';
|
||||
|
||||
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
|
||||
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
|
||||
@@ -16,6 +20,24 @@ export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: s
|
||||
reset();
|
||||
}, [reset, restoreDraft, sessionId]);
|
||||
|
||||
/**
|
||||
* Restore the pre-walk draft and drop the cached history walk.
|
||||
*
|
||||
* While the user is in an ArrowUp walk the textarea shows the recalled
|
||||
* command, so resetting the cursor alone would leave a deleted command ready
|
||||
* for Enter. Put the saved draft back first, exactly like the session-change
|
||||
* path above.
|
||||
*/
|
||||
const handleHistoryCleared = useCallback(() => {
|
||||
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
|
||||
reset();
|
||||
}, [reset, restoreDraft]);
|
||||
|
||||
useEffect(
|
||||
() => subscribeComposeBarHistoryCleared(handleHistoryCleared),
|
||||
[handleHistoryCleared],
|
||||
);
|
||||
|
||||
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
|
||||
|
||||
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
resetExternalMcpStartupSyncOnceForTests,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
shouldWaitForExternalMcpStartupReady,
|
||||
syncExternalMcpConfig,
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
@@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
const manager = createSessionIdleManager();
|
||||
try {
|
||||
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
|
||||
if (savedMinutes !== null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
|
||||
}
|
||||
const expected = savedMinutes ?? manager.getTimeoutMinutes();
|
||||
const received: number[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
savedMinutes,
|
||||
'sync does not migrate or overwrite the stored preference',
|
||||
);
|
||||
} finally {
|
||||
manager.clearAll();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
|
||||
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'togglePaneZoom',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
'toggleOpacity',
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
browserId: string;
|
||||
host: Host;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts: KnownHost[];
|
||||
terminalSettings: TerminalSettings;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
const opened = useRef(false);
|
||||
const mountedBrowserId = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { browserId, isVisible } = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let previous = '';
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native page views must leave the screen with their owning pane and
|
||||
// must not cover a renderer dialog or a toolbar's open menu.
|
||||
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || opened.current) return;
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt, browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedBrowserId.current = browserId;
|
||||
return () => {
|
||||
opened.current = false;
|
||||
mountedBrowserId.current = null;
|
||||
// StrictMode replays setup immediately. Do not destroy its pending URL
|
||||
// or native browser unless the owning panel actually remains unmounted.
|
||||
queueMicrotask(() => {
|
||||
if (mountedBrowserId.current === browserId) return;
|
||||
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
|
||||
});
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { updateRestoredSessionStatusState } from './sessionRestoreState';
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
|
||||
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const effective = (host: Host) => host;
|
||||
const host = (extra: Partial<Host> = {}): Host => ({
|
||||
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
|
||||
});
|
||||
|
||||
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
|
||||
let hosts = [initial, ...(extra.hosts ?? [])];
|
||||
let keys = extra.keys ?? [];
|
||||
let identities = extra.identities ?? [];
|
||||
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
|
||||
let find: ReturnType<typeof useHostOpenSessionReuse>;
|
||||
function Harness() {
|
||||
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
return {
|
||||
find: () => find(initial.id),
|
||||
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
|
||||
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
|
||||
keys = input.keys ?? keys;
|
||||
identities = input.identities ?? identities;
|
||||
sessions = input.sessions ?? sessions;
|
||||
await act(async () => renderer.update(React.createElement(Harness)));
|
||||
},
|
||||
close: () => act(async () => renderer.unmount()),
|
||||
};
|
||||
}
|
||||
|
||||
for (const [field, value] of [
|
||||
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
|
||||
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
|
||||
['hostChain', { hostIds: ['jump'] }],
|
||||
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
|
||||
] as const) {
|
||||
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
|
||||
const original = host({ password: 'old-password', authMethod: 'password' });
|
||||
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
|
||||
const original = host({ hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
|
||||
const original = host({ hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
|
||||
await api.update({ hosts: [original, changed] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
|
||||
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
|
||||
const original = host({ identityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
|
||||
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
|
||||
const keyApi = await harness(keyHost, { keys: [key] });
|
||||
t.after(keyApi.close);
|
||||
assert.equal((await keyApi.find())?.sessionId, 'tab');
|
||||
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
|
||||
assert.equal(await keyApi.find(), undefined);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
|
||||
] as const) {
|
||||
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
|
||||
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
|
||||
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
|
||||
t.after(api.close);
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const changedField of ['configuration', 'credentialId'] as const) {
|
||||
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
|
||||
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
|
||||
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
|
||||
const api = await harness(edited, { sessions: [restored] });
|
||||
t.after(api.close);
|
||||
assert.equal(await api.find(), undefined);
|
||||
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
|
||||
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
|
||||
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
|
||||
await api.update({ sessions: reconnecting });
|
||||
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
|
||||
});
|
||||
}
|
||||
|
||||
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
|
||||
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
|
||||
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
|
||||
t.after(api.close);
|
||||
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
|
||||
] as const) {
|
||||
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
|
||||
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
|
||||
await api.update({ identities });
|
||||
assert.equal(await api.find(), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
|
||||
});
|
||||
|
||||
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
|
||||
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
|
||||
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
|
||||
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, username: 'other-serial-user' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
|
||||
] as const) {
|
||||
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
|
||||
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
|
||||
const api = await harness(original, { sessions: [session] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
|
||||
t.after(alreadyEdited.close);
|
||||
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
|
||||
});
|
||||
}
|
||||
|
||||
test('local default shell, cwd and empty args match the renderer launch contract', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, localShellArgs: [] } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
|
||||
});
|
||||
|
||||
for (const [label, proxy] of [
|
||||
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
|
||||
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
|
||||
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
|
||||
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
|
||||
] as const) {
|
||||
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
|
||||
const original = host({ protocol: 'telnet' });
|
||||
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, ...proxy } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
}
|
||||
|
||||
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
|
||||
const original = host({ protocol: 'telnet', group: 'prod/web' });
|
||||
const groupConfigs: GroupConfig[] = [];
|
||||
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
|
||||
const api = await harness(original, { resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
|
||||
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
for (const [field, changed] of [
|
||||
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
|
||||
] as const) {
|
||||
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
|
||||
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = {
|
||||
terminalSettings: settings,
|
||||
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
|
||||
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
|
||||
if (owner === 'global') Object.assign(settings, { [field]: changed });
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, '/bin/bash');
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost' });
|
||||
const settings: { localShell?: string; localStartDir?: string } = {};
|
||||
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '');
|
||||
assert.equal(launched.localStartDir, '~');
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
|
||||
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
|
||||
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
|
||||
});
|
||||
|
||||
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
|
||||
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
|
||||
const context = {
|
||||
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
|
||||
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '/bin/bash');
|
||||
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
|
||||
assert.equal(defaultLaunch.localShell, '');
|
||||
assert.equal(defaultLaunch.localShellArgs, undefined);
|
||||
});
|
||||
|
||||
for (const discovered of [
|
||||
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
|
||||
]) {
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
|
||||
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, discovered.id);
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
context.discoveredShells = [discovered];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
|
||||
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, discovered.id);
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
|
||||
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const api = await harness(original, { resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { findReusableHostSession } from '../../domain/hostOpenSession';
|
||||
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
|
||||
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
|
||||
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
|
||||
type HostOpenSessionReuseInput = {
|
||||
sessions: TerminalSession[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
resolveEffectiveHost: (host: Host) => Host;
|
||||
};
|
||||
|
||||
type OpeningConnection = { localHost: Host } | { key: Promise<string | undefined> };
|
||||
|
||||
/** Retain the opening connection identity, never credentials in session storage. */
|
||||
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
|
||||
const openingKeys = useRef(new Map<string, OpeningConnection>());
|
||||
const connectionKey = useCallback((host: Host) => {
|
||||
const protocol = resolveEffectiveTerminalProtocol(host);
|
||||
if (protocol === 'local') {
|
||||
const options = {
|
||||
hostname: host.hostname,
|
||||
username: '',
|
||||
localShell: host.localShell || undefined,
|
||||
// The renderer starter omits both absent and empty args so the
|
||||
// backend chooses its discovered/login defaults for either form.
|
||||
localShellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
|
||||
localStartDir: host.localStartDir || '~',
|
||||
};
|
||||
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
|
||||
}
|
||||
if (protocol === 'telnet') {
|
||||
// The actual Telnet starter rejects these settings before connecting.
|
||||
if ((host.proxyProfileId && !host.proxyConfig)
|
||||
|| findMissingProxyIdentityId(host.proxyConfig, identities)
|
||||
|| findIncompleteProxyIdentityId(host.proxyConfig, identities)
|
||||
|| hasUsableProxyConfig(host.proxyConfig)) {
|
||||
throw new Error('Telnet proxy configuration cannot be used');
|
||||
}
|
||||
// Match the actual Telnet starter, including explicit empty overrides.
|
||||
const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined;
|
||||
if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) {
|
||||
throw new Error('Telnet identity is missing or incomplete');
|
||||
}
|
||||
const rawPassword = identity ? identity.password : resolveTelnetPassword(host);
|
||||
if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable');
|
||||
const options = {
|
||||
hostname: host.hostname,
|
||||
port: resolveTelnetPort(host),
|
||||
username: identity ? identity.username?.trim() : resolveTelnetUsername(host),
|
||||
password: sanitizeCredentialValue(rawPassword),
|
||||
};
|
||||
return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options });
|
||||
}
|
||||
// Reuse the existing credential/chain expansion and transport identity
|
||||
// comparison. Presentation names and SFTP-only choices do not change a
|
||||
// terminal's destination or login.
|
||||
if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable');
|
||||
const options = buildSftpHostCredentials({
|
||||
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
|
||||
});
|
||||
delete options.sudo;
|
||||
delete options.fileProtocol;
|
||||
for (const hop of options.jumpHosts ?? []) delete hop.label;
|
||||
const connectionOptions = {
|
||||
...options,
|
||||
protocol,
|
||||
moshEnabled: host.moshEnabled,
|
||||
moshServerPath: host.moshServerPath,
|
||||
etEnabled: host.etEnabled,
|
||||
etPort: host.etPort,
|
||||
etJumpPorts: protocol === 'et'
|
||||
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
|
||||
: undefined,
|
||||
pluginConnection: host.pluginConnection,
|
||||
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
|
||||
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
|
||||
agentForwarding: host.agentForwarding,
|
||||
x11Forwarding: host.x11Forwarding,
|
||||
};
|
||||
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
|
||||
}, [hosts, keys, identities, resolveEffectiveHost]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const liveIds = new Set(sessions.map((session) => session.id));
|
||||
for (const id of openingKeys.current.keys()) {
|
||||
if (!liveIds.has(id)) openingKeys.current.delete(id);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (openingKeys.current.has(session.id) || session.restoreState) continue;
|
||||
const host = hosts.find((candidate) => candidate.id === session.hostId);
|
||||
if (!host) continue;
|
||||
try {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const openingHost = {
|
||||
...effectiveHost,
|
||||
hostname: session.hostname,
|
||||
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
|
||||
port: session.port ?? host.port,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
serialConfig: session.serialConfig ?? host.serialConfig,
|
||||
pluginConnection: session.pluginConnection ?? host.pluginConnection,
|
||||
localShell: session.localShell ?? effectiveHost.localShell,
|
||||
localShellArgs: session.localShellArgs ?? effectiveHost.localShellArgs,
|
||||
localStartDir: session.localStartDir ?? effectiveHost.localStartDir,
|
||||
};
|
||||
// Local IDs may only resolve after renderer discovery completes. Keep
|
||||
// the opening settings so that later discovery can identify the same
|
||||
// executable without replacing them with edited vault/global settings.
|
||||
openingKeys.current.set(session.id, resolveEffectiveTerminalProtocol(openingHost) === 'local'
|
||||
? { localHost: openingHost }
|
||||
: { key: connectionKey(openingHost).catch(() => undefined) });
|
||||
} catch {
|
||||
openingKeys.current.set(session.id, { key: Promise.resolve(undefined) });
|
||||
}
|
||||
}
|
||||
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
|
||||
|
||||
return useCallback(async (hostId: string) => {
|
||||
const host = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!host) return undefined;
|
||||
try {
|
||||
const requestedKey = await connectionKey(resolveEffectiveHost(host));
|
||||
const matches = await Promise.all(sessions.map(async (session) => {
|
||||
if (session.hostId !== hostId) return undefined;
|
||||
const opening = openingKeys.current.get(session.id);
|
||||
const openingKey = opening && ('localHost' in opening
|
||||
// An explicit empty selector means backend default, not today's
|
||||
// global setting. Nonempty captured IDs can safely resolve now.
|
||||
? connectionKey(opening.localHost.localShell ? resolveEffectiveHost(opening.localHost) : opening.localHost)
|
||||
: opening.key);
|
||||
return await openingKey === requestedKey ? session : undefined;
|
||||
}));
|
||||
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
|
||||
} catch {
|
||||
// An unresolved/changed credential must never select an old transport.
|
||||
return undefined;
|
||||
}
|
||||
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
|
||||
}
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user