mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
180
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd1c2786b3 | ||
|
|
be43afe31e | ||
|
|
e86f38203c | ||
|
|
b7dc61efb0 | ||
|
|
6e5d08c005 | ||
|
|
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 | ||
|
|
90ab87efcc | ||
|
|
4cd6a7191d | ||
|
|
3a0528bbd4 | ||
|
|
c349d4716f | ||
|
|
f7483f0593 | ||
|
|
1209bfa61c | ||
|
|
6baa87e3c3 | ||
|
|
a1d1115e0a | ||
|
|
16376a2117 | ||
|
|
f0833049e0 | ||
|
|
75c155b748 | ||
|
|
6fcd6a3ec3 | ||
|
|
de6d1a28ba | ||
|
|
b2ed83ae51 | ||
|
|
f91b2cd04e | ||
|
|
1d10d1679e | ||
|
|
8d2058267a | ||
|
|
8020eaa427 | ||
|
|
3298a70f32 | ||
|
|
e7202f392a | ||
|
|
9423a1d1cd | ||
|
|
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
|
||||
|
||||
@@ -70,3 +73,26 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -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' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { focusEditorInWindow } from '../state/editorWindowClient';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
@@ -71,6 +72,16 @@ export function AppActiveTabChrome({
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorTabId(activeTabId)) return;
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
if (!editorId) return;
|
||||
const tab = editorTabs.find((entry) => entry.id === editorId);
|
||||
if (tab?.placement !== 'window') return;
|
||||
void focusEditorInWindow(editorId);
|
||||
setActiveTabId('vault');
|
||||
}, [activeTabId, editorTabs, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
|
||||
@@ -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,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("deep-link tab names are applied to terminal sessions", () => {
|
||||
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
|
||||
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
|
||||
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
|
||||
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
|
||||
);
|
||||
assert.match(source, /label: tabName/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { createHostTerminalSession } from '../state/sessionFactories';
|
||||
|
||||
// Exercise the production application callback, including its async command
|
||||
// resolution boundary, before the state hook snapshots the shell type.
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const script of [false, true]) {
|
||||
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
|
||||
let session: ReturnType<typeof createHostTerminalSession> | undefined;
|
||||
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
|
||||
(value: unknown) => value,
|
||||
() => script,
|
||||
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
|
||||
session = createHostTerminalSession('snippet-local', hosts[0]);
|
||||
},
|
||||
async () => 'echo hello',
|
||||
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: 'cmd' },
|
||||
resolveShellSetting: () => ({ command: 'cmd.exe' }),
|
||||
}),
|
||||
);
|
||||
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
|
||||
assert.equal(session?.protocol, 'local');
|
||||
assert.equal(session?.localShell, 'cmd.exe');
|
||||
assert.equal(session?.shellType, 'cmd');
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { installEditorWindowSourceListeners } from '../state/editorWindowClient';
|
||||
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -37,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';
|
||||
@@ -52,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';
|
||||
@@ -98,6 +102,11 @@ type OpenSessionInNewWindowPayload = {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
type DeepLinkPayload = {
|
||||
url?: string;
|
||||
tabName?: string;
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
|
||||
@@ -143,6 +152,7 @@ export function AppSideEffects() {
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
toggleWindowOpacity,
|
||||
} = settings;
|
||||
|
||||
// Always publish terminal keyboard focus to the main process. When font zoom
|
||||
@@ -260,24 +270,9 @@ export function AppSideEffects() {
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
getSessionRestoreCwd,
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
// AppSideEffects only needs host/session maps for glue handlers and effects.
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
},
|
||||
[runSnippet],
|
||||
);
|
||||
|
||||
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
|
||||
@@ -635,6 +630,8 @@ export function AppSideEffects() {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
useEffect(() => installEditorWindowSourceListeners(), []);
|
||||
|
||||
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
@@ -1084,6 +1081,7 @@ export function AppSideEffects() {
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
toggleWorkspaceViewMode,
|
||||
workspaces: workspacesRefForHotkeys.current,
|
||||
}), action, e);
|
||||
@@ -1096,6 +1094,7 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
moveFocusInWorkspace,
|
||||
toggleBroadcast,
|
||||
toggleWindowOpacity,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
confirmIfBusyLocalTerminal,
|
||||
@@ -1201,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);
|
||||
@@ -1357,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));
|
||||
@@ -1389,14 +1417,25 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (sessionId && tabName) {
|
||||
renameSessionInline(sessionId, tabName);
|
||||
}
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
@@ -1415,6 +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.` };
|
||||
@@ -1457,6 +1500,7 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
@@ -1465,15 +1509,16 @@ export function AppSideEffects() {
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
@@ -1502,25 +1547,26 @@ export function AppSideEffects() {
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
const hostDraft = buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
});
|
||||
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
@@ -1537,9 +1583,10 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
@@ -1560,10 +1607,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1572,10 +1619,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
@@ -36,6 +37,7 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
@@ -259,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,
|
||||
@@ -353,14 +355,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
defaultLocalShell,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
@@ -420,6 +424,23 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
if (tab.placement === "window") {
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
|
||||
const ok = Boolean(result?.success) && result?.cancelled !== true;
|
||||
if (ok) {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() === closingTabId) {
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
@@ -431,7 +452,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
@@ -462,6 +483,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handlePopOutEditorTab = useCallback(async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab || tab.placement === "window") {
|
||||
if (tab) void focusEditorInWindow(id);
|
||||
return;
|
||||
}
|
||||
const host = hostById.get(tab.hostId);
|
||||
await popOutEditorTab(id, host?.label);
|
||||
}, [hostById]);
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
@@ -538,6 +569,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -716,6 +749,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onClearCommandHistory={clearAllCommandHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -788,7 +822,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
))}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
@@ -797,6 +831,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
|
||||
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { editorTabStore } from "../state/editorTabStore.ts";
|
||||
import { popOutEditorTab } from "../state/editorWindowClient.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
|
||||
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const editorId = "quit-during-editor-handoff";
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
editorTabStore.close(editorId);
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
let query!: () => Promise<void>;
|
||||
let accept!: (result: { success: boolean }) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(toast, "warning", () => {});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
openEditorWindow: () => receipt,
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
|
||||
}));
|
||||
function SourceWindow() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
|
||||
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
|
||||
const opening = popOutEditorTab(editorId);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
|
||||
accept({ success: true });
|
||||
assert.equal(await opening, true);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.content, "");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
|
||||
});
|
||||
@@ -357,6 +357,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
@@ -541,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,10 +1,12 @@
|
||||
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…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.modelCatalogRetry': 'Retry',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
@@ -336,6 +338,13 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Always',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
|
||||
'ai.externalMcp.allowAll': 'Allow all actions',
|
||||
'ai.externalMcp.allowSession': 'This connection',
|
||||
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
|
||||
'ai.externalMcp.allowHost': 'This server',
|
||||
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
@@ -505,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',
|
||||
@@ -514,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',
|
||||
|
||||
@@ -312,8 +312,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
@@ -764,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)',
|
||||
@@ -1120,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',
|
||||
@@ -1145,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",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -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',
|
||||
@@ -61,6 +68,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
@@ -134,6 +142,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.cut': 'Cut',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
@@ -197,6 +206,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.connected': 'Connected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
@@ -307,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',
|
||||
@@ -378,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.',
|
||||
@@ -515,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)',
|
||||
@@ -866,6 +886,13 @@ export const enTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.popOut': 'Open in editor window',
|
||||
'sftp.editor.dock': 'Dock to main window',
|
||||
'sftp.editor.focusWindow': 'Focus editor window',
|
||||
'sftp.editor.close': 'Close',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No files open',
|
||||
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
|
||||
@@ -407,6 +407,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transfer next',
|
||||
'sftp.transferCenter.pauseAll': 'Pause all',
|
||||
'sftp.transferCenter.resumeAll': 'Resume all',
|
||||
'sftp.transferCenter.cancelAll': 'Cancel all',
|
||||
'sftp.transferCenter.empty': 'No transfers in this category',
|
||||
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
|
||||
'sftp.transferCenter.hideBackground': 'Hide background transfers',
|
||||
@@ -714,6 +715,16 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'vault.panel.resizeWidth': 'Resize panel width',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Test Connection',
|
||||
'hostConnectionTest.test': 'Test',
|
||||
'hostConnectionTest.chainOf': 'Chain {current} of {total}',
|
||||
'hostConnectionTest.success': 'Connection successful',
|
||||
'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.',
|
||||
'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s',
|
||||
'hostConnectionTest.failed': 'Connection failed.',
|
||||
'hostConnectionTest.retry': 'Retry',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.',
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
@@ -900,9 +911,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.singleChannelSsh': 'Single-channel SSH (bastion)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Do not open extra SFTP or exec channels on the terminal SSH connection. Recognized one-channel bastion banners do this automatically. Turn this on when the bastion still advertises OpenSSH. File transfer uses a separate login.',
|
||||
'hostDetails.singleChannelSsh.warning': 'System Manager is unavailable while this is on. File transfer uses a second login and may be limited by concurrent-session policy.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
@@ -1010,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,10 +1,12 @@
|
||||
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…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.modelCatalogRetry': 'Reintentar',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
@@ -327,6 +329,13 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Siempre',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
|
||||
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
|
||||
'ai.externalMcp.allowSession': 'Esta conexión',
|
||||
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
|
||||
'ai.externalMcp.allowHost': 'Este servidor',
|
||||
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
@@ -496,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',
|
||||
@@ -505,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',
|
||||
|
||||
@@ -227,8 +227,11 @@ export const esCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
|
||||
'settings.globalHotkey.notSet': 'No configurada',
|
||||
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
|
||||
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
|
||||
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
|
||||
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
|
||||
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
|
||||
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
|
||||
@@ -679,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)',
|
||||
@@ -880,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',
|
||||
@@ -1078,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',
|
||||
@@ -1103,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",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -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',
|
||||
@@ -61,6 +68,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
@@ -134,6 +142,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.cut': 'Cortar',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
@@ -307,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',
|
||||
@@ -378,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.',
|
||||
@@ -515,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)',
|
||||
@@ -866,6 +885,13 @@ export const esTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.popOut': 'Abrir en ventana de editor',
|
||||
'sftp.editor.dock': 'Anclar a la ventana principal',
|
||||
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
|
||||
'sftp.editor.close': 'Cerrar',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
|
||||
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
|
||||
@@ -407,6 +407,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transferir a continuación',
|
||||
'sftp.transferCenter.pauseAll': 'Pausar todas',
|
||||
'sftp.transferCenter.resumeAll': 'Reanudar todas',
|
||||
'sftp.transferCenter.cancelAll': 'Cancelar todas',
|
||||
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
|
||||
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
|
||||
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
|
||||
@@ -900,9 +901,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Modo de dispositivo de red',
|
||||
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
|
||||
'hostDetails.singleChannelSsh': 'SSH de un solo canal (bastión)',
|
||||
'hostDetails.singleChannelSsh.desc': 'No abrir canales extra de SFTP o exec en la conexión SSH del terminal. Los banners de bastión de un solo canal ya reconocidos lo hacen solos. Actívalo si el bastión sigue anunciando OpenSSH. La transferencia de archivos usa un inicio de sesión aparte.',
|
||||
'hostDetails.singleChannelSsh.warning': 'El administrador del sistema no estará disponible. La transferencia de archivos usa un segundo inicio de sesión y puede estar limitada por la política de sesiones concurrentes.',
|
||||
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
|
||||
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
|
||||
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
|
||||
@@ -1010,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,11 +1,13 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.modelCatalogRetry': 'Повторить',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
@@ -283,6 +285,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Всегда',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
|
||||
'ai.externalMcp.allowAll': 'Разрешить все действия',
|
||||
'ai.externalMcp.allowSession': 'Это подключение',
|
||||
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
|
||||
'ai.externalMcp.allowHost': 'Этот сервер',
|
||||
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
@@ -452,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': 'Показать всё',
|
||||
@@ -461,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': 'Хосты',
|
||||
|
||||
@@ -296,8 +296,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
@@ -715,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': 'Нет (нажимать напрямую)',
|
||||
@@ -905,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": "Сервер удалён",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -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': 'Настройки терминала',
|
||||
@@ -82,6 +89,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
@@ -155,6 +163,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.cut': 'Вырезать',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
@@ -218,6 +227,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.connected': 'Подключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
@@ -325,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',
|
||||
@@ -396,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': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
@@ -533,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': 'Перезаписать облако (сохранить локальную)',
|
||||
@@ -873,6 +893,13 @@ export const ruTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Перенос строк',
|
||||
'sftp.editor.maximize': 'Развернуть',
|
||||
'sftp.editor.popOut': 'Открыть в окне редактора',
|
||||
'sftp.editor.dock': 'Вернуть в главное окно',
|
||||
'sftp.editor.focusWindow': 'Перейти к окну редактора',
|
||||
'sftp.editor.close': 'Закрыть',
|
||||
'sftp.editor.windowTitle': 'Редактор',
|
||||
'sftp.editor.windowEmpty': 'Нет открытых файлов',
|
||||
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
|
||||
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
|
||||
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
|
||||
'sftp.editor.discardChanges': 'Отбросить',
|
||||
|
||||
@@ -102,6 +102,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.tree.expandAll': 'Развернуть все',
|
||||
'vault.tree.collapseAll': 'Свернуть все',
|
||||
'vault.hosts.newHost': 'Новый хост',
|
||||
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
|
||||
'vault.hosts.newGroup': 'Новая группа',
|
||||
'vault.hosts.import': 'Импорт',
|
||||
'vault.hosts.export': 'Экспорт',
|
||||
@@ -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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -445,6 +449,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Передать следующей',
|
||||
'sftp.transferCenter.pauseAll': 'Приостановить все',
|
||||
'sftp.transferCenter.resumeAll': 'Продолжить все',
|
||||
'sftp.transferCenter.cancelAll': 'Отменить все',
|
||||
'sftp.transferCenter.empty': 'В этой категории нет передач',
|
||||
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
|
||||
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
|
||||
@@ -752,6 +757,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'vault.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostConnectionTest.title': 'Проверка подключения',
|
||||
'hostConnectionTest.test': 'Проверить',
|
||||
'hostConnectionTest.chainOf': 'Цепочка {current} из {total}',
|
||||
'hostConnectionTest.success': 'Подключение успешно',
|
||||
'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.',
|
||||
'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с',
|
||||
'hostConnectionTest.failed': 'Не удалось подключиться.',
|
||||
'hostConnectionTest.retry': 'Повторить',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.',
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
@@ -935,9 +949,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Режим сетевого устройства',
|
||||
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.singleChannelSsh': 'Одноканальный SSH (бастион)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Не открывать дополнительные SFTP/exec-каналы на SSH-соединении терминала. Уже распознанные баннеры одноканальных бастионов обрабатываются сами. Включите, если бастион всё ещё представляется как OpenSSH. Передача файлов идёт отдельным входом.',
|
||||
'hostDetails.singleChannelSsh.warning': 'Системная панель будет недоступна. Передача файлов использует отдельный вход и может быть ограничена политикой одновременных сессий.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
@@ -1044,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,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -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,10 +1,12 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重试',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
@@ -336,6 +338,13 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始终',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
|
||||
'ai.externalMcp.allowAll': '允许所有操作',
|
||||
'ai.externalMcp.allowSession': '此次连接',
|
||||
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此服务器',
|
||||
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -505,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': '查看全部',
|
||||
@@ -514,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': '主机',
|
||||
|
||||
@@ -316,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
|
||||
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
|
||||
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
@@ -737,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': '导出',
|
||||
@@ -762,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': '设置你的主机',
|
||||
@@ -1048,6 +1055,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '优先传输',
|
||||
'sftp.transferCenter.pauseAll': '全部暂停',
|
||||
'sftp.transferCenter.resumeAll': '全部继续',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '当前分类没有传输任务',
|
||||
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
|
||||
'sftp.transferCenter.hideBackground': '隐藏后台任务',
|
||||
|
||||
@@ -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": "主机已删除",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -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': '剪切文件',
|
||||
@@ -968,6 +978,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在编辑器窗口打开',
|
||||
'sftp.editor.dock': '停靠回主窗口',
|
||||
'sftp.editor.focusWindow': '聚焦编辑器窗口',
|
||||
'sftp.editor.close': '关闭',
|
||||
'sftp.editor.windowTitle': '编辑器',
|
||||
'sftp.editor.windowEmpty': '没有打开的文件',
|
||||
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
|
||||
@@ -130,6 +130,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'vault.panel.resizeWidth': '调整面板宽度',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostConnectionTest.title': '测试连接',
|
||||
'hostConnectionTest.test': '测试',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '连接成功',
|
||||
'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。',
|
||||
'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时',
|
||||
'hostConnectionTest.failed': '连接失败。',
|
||||
'hostConnectionTest.retry': '重试',
|
||||
'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -316,9 +325,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.singleChannelSsh': '单通道 SSH(堡垒机)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在当前终端 SSH 上再开 SFTP/系统探测等通道。已识别的单通道堡垒机软件标识会自动分开登录。仍显示 OpenSSH 标识的堡垒机需要手动开启。文件传输会改走独立连接。',
|
||||
'hostDetails.singleChannelSsh.warning': '开启后系统面板不可用。文件传输使用单独登录,可能受堡垒机并发会话限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
@@ -384,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': '未安装连接提供程序',
|
||||
@@ -447,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 接收',
|
||||
@@ -469,6 +493,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
@@ -540,6 +565,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.cut': '剪切',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
@@ -592,6 +618,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.waitingForUserInput': '等待用户输入',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.connected': '已连接',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
@@ -693,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 兼容存储',
|
||||
@@ -764,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': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
@@ -900,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,10 +1,12 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重試',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
@@ -326,6 +328,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始終',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
|
||||
'ai.externalMcp.allowAll': '允許所有操作',
|
||||
'ai.externalMcp.allowSession': '此次連線',
|
||||
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此伺服器',
|
||||
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -495,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': '檢視全部',
|
||||
@@ -504,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': '主機',
|
||||
|
||||
@@ -318,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
|
||||
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
|
||||
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
@@ -739,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': '匯出',
|
||||
@@ -764,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': '設定你的主機',
|
||||
@@ -1050,6 +1057,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '優先傳輸',
|
||||
'sftp.transferCenter.pauseAll': '全部暫停',
|
||||
'sftp.transferCenter.resumeAll': '全部繼續',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
|
||||
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
|
||||
'sftp.transferCenter.hideBackground': '隱藏背景工作',
|
||||
|
||||
@@ -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": "主機已刪除",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -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': '剪下檔案',
|
||||
@@ -968,6 +978,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自動換行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在編輯器視窗開啟',
|
||||
'sftp.editor.dock': '停靠回主視窗',
|
||||
'sftp.editor.focusWindow': '聚焦編輯器視窗',
|
||||
'sftp.editor.close': '關閉',
|
||||
'sftp.editor.windowTitle': '編輯器',
|
||||
'sftp.editor.windowEmpty': '沒有開啟的檔案',
|
||||
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
|
||||
'sftp.editor.unsavedTitle': '未儲存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
|
||||
'sftp.editor.discardChanges': '不儲存',
|
||||
|
||||
@@ -129,6 +129,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新增主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostConnectionTest.title': '測試連線',
|
||||
'hostConnectionTest.test': '測試',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '連線成功',
|
||||
'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。',
|
||||
'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時',
|
||||
'hostConnectionTest.failed': '連線失敗。',
|
||||
'hostConnectionTest.retry': '重試',
|
||||
'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。',
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
@@ -316,9 +325,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '網路裝置模式',
|
||||
'hostDetails.deviceType.desc': '適用於透過 SSH 連線的網路裝置(交換機、路由器、防火牆)。指令將原樣傳送,不進行 Shell 包裝,相容華為 VRP、Cisco IOS 等廠商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理指令將直接傳送,無法取得結束代碼。僅建議在裝置不執行標準 Shell 時啟用。',
|
||||
'hostDetails.singleChannelSsh': '單通道 SSH(堡壘機)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在目前終端 SSH 上再開 SFTP/系統探測等通道。已識別的單通道堡壘機軟體識別字串會自動分開登入。仍顯示 OpenSSH 識別字串的堡壘機需要手動開啟。檔案傳輸會改走獨立連線。',
|
||||
'hostDetails.singleChannelSsh.warning': '開啟後系統面板無法使用。檔案傳輸使用單獨登入,可能受堡壘機並行會話限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 演算法',
|
||||
'hostDetails.section.terminalBehavior': '終端行為',
|
||||
'hostDetails.lineTimestamps': '顯示輸出時間',
|
||||
@@ -384,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': '尚未安裝連線提供者',
|
||||
@@ -447,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 接收',
|
||||
@@ -469,6 +493,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
@@ -540,6 +565,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.cut': '剪下',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
@@ -592,6 +618,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
|
||||
'terminal.progress.waitingForUserInput': '等待使用者輸入',
|
||||
'terminal.progress.disconnected': '已斷開',
|
||||
'terminal.progress.connected': '已連線',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新開始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
|
||||
@@ -693,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 相容儲存',
|
||||
@@ -764,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': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
@@ -900,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,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { editorTabStore } from "../../application/state/editorTabStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
|
||||
|
||||
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
|
||||
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const original = {
|
||||
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(original, "window");
|
||||
const before = editorTabStore.getTab(original.editorId);
|
||||
t.after(() => editorTabStore.close(original.editorId));
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: async () => { focusCount++; return { success: true }; },
|
||||
}));
|
||||
const errors = t.mock.method(toast, "error", () => {});
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
|
||||
});
|
||||
const modal = { ...original, content: "modal edits" };
|
||||
await act(async () => { ops[action](modal); });
|
||||
assert.equal(ops.showTextEditor, true);
|
||||
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
|
||||
assert.equal(ops.textEditorContent, "original");
|
||||
assert.equal(modal.content, "modal edits");
|
||||
assert.equal(editorTabStore.getTab(original.editorId), before);
|
||||
assert.equal(focusCount, 0);
|
||||
assert.equal(errors.mock.callCount(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const focusSucceeds of [true, false]) {
|
||||
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const detached = {
|
||||
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(detached, "window");
|
||||
const before = editorTabStore.getTab(detached.editorId);
|
||||
t.after(() => editorTabStore.close(detached.editorId));
|
||||
let finishFocus!: (result: { success: boolean }) => void;
|
||||
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: () => { focusCount++; return focusing; },
|
||||
}));
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
|
||||
});
|
||||
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
|
||||
assert.equal(focusCount, 1);
|
||||
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
|
||||
await act(async () => { finishFocus({ success: focusSucceeds }); });
|
||||
assert.equal(ops.showTextEditor, !focusSucceeds);
|
||||
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
|
||||
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
|
||||
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
|
||||
t.mock.method(Date, "now", () => 1234567890);
|
||||
const [firstRenderer, secondRenderer] = await Promise.all([
|
||||
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
|
||||
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
|
||||
]);
|
||||
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
|
||||
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
|
||||
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
|
||||
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
|
||||
});
|
||||
@@ -28,6 +28,20 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
|
||||
store._debugInsert(tab);
|
||||
const id = store.promoteFromModal({
|
||||
...makeTab(),
|
||||
remotePath: "/etc//nginx/./nginx.conf",
|
||||
});
|
||||
assert.equal(id, tab.id);
|
||||
assert.equal(store.getTab(id), tab);
|
||||
assert.equal(store.isDirty(id), true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
@@ -23,6 +24,7 @@ export interface EditorTab {
|
||||
sftpTabId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
@@ -32,12 +34,16 @@ export interface EditorTab {
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
placement?: EditorTabPlacement;
|
||||
windowDirty?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
|
||||
|
||||
export const tabIsDirty = (tab: EditorTab): boolean =>
|
||||
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
@@ -79,7 +85,82 @@ export class EditorTabStore {
|
||||
};
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
return !!t && tabIsDirty(t);
|
||||
};
|
||||
|
||||
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
|
||||
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
|
||||
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
|
||||
this.patch(id, {
|
||||
placement: "window",
|
||||
windowDirty: dirty,
|
||||
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
|
||||
});
|
||||
this.notifyStructural();
|
||||
};
|
||||
|
||||
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
|
||||
const tab = this.getTab(id);
|
||||
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
|
||||
this.patch(id, { windowDirty: dirty });
|
||||
};
|
||||
|
||||
upsertFromSnapshot = (
|
||||
snapshot: EditorWindowTabSnapshot,
|
||||
placement: EditorTabPlacement = "tab",
|
||||
): EditorTabId => {
|
||||
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = existingById ?? this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
const next: Partial<EditorTab> = {
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: placement === "window" ? "" : snapshot.content,
|
||||
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
|
||||
placement,
|
||||
windowDirty: placement === "window"
|
||||
? snapshot.content !== snapshot.baselineContent
|
||||
: false,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
if (existing) {
|
||||
this.patch(existing.id, next);
|
||||
if (existing.placement !== placement) this.notifyStructural();
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: snapshot.editorId || this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: next.content ?? "",
|
||||
baselineContent: next.baselineContent ?? "",
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: (next.viewState ?? null) as EditorTab["viewState"],
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement,
|
||||
windowDirty: next.windowDirty === true,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
@@ -91,6 +172,12 @@ export class EditorTabStore {
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
const tab = this.getTab(id);
|
||||
if (tab?.placement === "window") {
|
||||
// Only the detached renderer has the current content. Wait for its dirty report.
|
||||
this.patch(id, { savingState: "idle", saveError: null });
|
||||
return;
|
||||
}
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
@@ -174,17 +261,26 @@ export class EditorTabStore {
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
placement?: EditorTabPlacement;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.placement === "window") {
|
||||
if (snapshot.content !== snapshot.baselineContent) {
|
||||
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
placement: snapshot.placement ?? existing.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
@@ -204,6 +300,8 @@ export class EditorTabStore {
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement: snapshot.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
@@ -221,9 +319,9 @@ export class EditorTabStore {
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
@@ -332,9 +430,11 @@ export type EditorTabChrome = Pick<
|
||||
| 'sessionId'
|
||||
| 'sftpTabId'
|
||||
| 'hostId'
|
||||
| 'hostLabel'
|
||||
| 'remotePath'
|
||||
| 'fileName'
|
||||
| 'languageId'
|
||||
| 'placement'
|
||||
>;
|
||||
|
||||
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
@@ -343,9 +443,11 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
placement: tab.placement ?? "tab",
|
||||
});
|
||||
|
||||
export const useHasEditorTabForSessions = (
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { editorTabStore } from "./editorTabStore.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
|
||||
const snapshot = {
|
||||
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file", fileName: "file", languageId: "plaintext",
|
||||
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
|
||||
};
|
||||
|
||||
for (const latestContent of ["v3", "v2", "v1"]) {
|
||||
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let finish!: (result: { ok: boolean }) => void;
|
||||
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
saveEditorWindowTab: (request: { content: string }) => {
|
||||
assert.equal(request.content, "v2");
|
||||
return pending;
|
||||
},
|
||||
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
|
||||
}));
|
||||
const saving = saveDetachedEditorTab(snapshot.editorId);
|
||||
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
|
||||
finish({ ok: true });
|
||||
assert.deepEqual(await saving, { ok: true });
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
|
||||
assert.deepEqual(reports, [latestContent !== "v2"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot, "window");
|
||||
t.after(() => {
|
||||
editorTabStore.close(snapshot.editorId);
|
||||
releaseEditorTabSaveCoordinator(snapshot.editorId);
|
||||
});
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { finish = resolve; });
|
||||
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
|
||||
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
|
||||
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
|
||||
const receipts: EditorWindowSaveResult[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
|
||||
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
|
||||
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
|
||||
}));
|
||||
t.after(installEditorWindowSourceListeners());
|
||||
const saving = onSave({ ...snapshot, requestId: "request" });
|
||||
onDirty({ editorId: snapshot.editorId, dirty: true });
|
||||
finish();
|
||||
await saving;
|
||||
assert.equal(receipts[0]?.ok, true);
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
|
||||
onDirty({ editorId: snapshot.editorId, dirty: false });
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
|
||||
});
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
|
||||
const opening = popOutEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted" });
|
||||
assert.equal(await opening, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
|
||||
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
|
||||
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean; error?: string }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
dockEditorWindowTab: (sent: typeof snapshot) => {
|
||||
calls++;
|
||||
assert.equal(sent.content, "v2");
|
||||
return receipt;
|
||||
},
|
||||
}));
|
||||
const docking = dockDetachedEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 1);
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
|
||||
assert.equal((await docking).success, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
|
||||
if (outcome !== "accepted") {
|
||||
assert.equal(tab.saveError, "previous save failed");
|
||||
editorTabStore.updateContent(snapshot.editorId, "v3", null);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("dock does not take over an in-progress save", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "saving");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
|
||||
});
|
||||
|
||||
test("a closing destination rejects a popout without releasing source content", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
|
||||
assert.equal(await popOutEditorTab(snapshot.editorId), false);
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, "tab");
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { saveEditorTab } from "./editorTabSave";
|
||||
import {
|
||||
editorTabStore,
|
||||
tabIsDirty,
|
||||
type EditorTab,
|
||||
type EditorTabId,
|
||||
} from "./editorTabStore";
|
||||
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
import { activeTabStore, toEditorTabId } from "./activeTabStore";
|
||||
|
||||
export function toEditorWindowSnapshot(
|
||||
tab: EditorTab,
|
||||
hostLabel?: string,
|
||||
): EditorWindowTabSnapshot {
|
||||
return {
|
||||
editorId: tab.id,
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: hostLabel || tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
content: tab.content,
|
||||
baselineContent: tab.baselineContent,
|
||||
wordWrap: tab.wordWrap,
|
||||
viewState: tab.viewState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openEditorWindow) return false;
|
||||
const result = await bridge.openEditorWindow(snapshot);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
|
||||
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function popOutEditorTab(
|
||||
tabId: EditorTabId,
|
||||
hostLabel?: string,
|
||||
): Promise<boolean> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return false;
|
||||
if (tab.placement === "window") {
|
||||
return focusEditorInWindow(tabId);
|
||||
}
|
||||
const dirty = tabIsDirty(tab);
|
||||
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
|
||||
// Hide the source editor during transfer, but retain its only owned copy
|
||||
// until the destination has acknowledged installing the tab.
|
||||
editorTabStore.markDetached(tabId, dirty, false);
|
||||
const ok = await openEditorInWindow(snapshot).catch(() => false);
|
||||
if (!ok) {
|
||||
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
|
||||
return false;
|
||||
}
|
||||
editorTabStore.markDetached(tabId, dirty);
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (activeId === toEditorTabId(tabId)) {
|
||||
activeTabStore.setActiveTabId("vault");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeOwnedEditorWindowTabs(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
options?: { force?: boolean },
|
||||
): Promise<{ cancelled: boolean; closedIds: string[] }> {
|
||||
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
|
||||
if (matching.length === 0) return { cancelled: false, closedIds: [] };
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: options?.force === true,
|
||||
});
|
||||
const closedIds = result?.closedIds ?? [];
|
||||
for (const id of closedIds) editorTabStore.close(id);
|
||||
if (!result || result.success === false) {
|
||||
return { cancelled: options?.force !== true, closedIds };
|
||||
}
|
||||
if (result.cancelled !== true) {
|
||||
for (const tab of matching) editorTabStore.close(tab.id);
|
||||
}
|
||||
return {
|
||||
cancelled: result.cancelled === true,
|
||||
closedIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmCloseOwnedEditors(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> {
|
||||
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
|
||||
if (windowClose.cancelled) return false;
|
||||
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
|
||||
}
|
||||
|
||||
export function forceCloseOwnedEditors(owners: {
|
||||
sessionIds?: readonly string[];
|
||||
sftpTabIds?: readonly string[];
|
||||
}): EditorTabId[] {
|
||||
const matching = editorTabStore.getTabs().filter((tab) => {
|
||||
if (tab.placement !== "window") return false;
|
||||
if (owners.sessionIds?.includes(tab.sessionId)) return true;
|
||||
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
|
||||
return false;
|
||||
});
|
||||
if (matching.length > 0) {
|
||||
void netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
return editorTabStore.forceCloseByOwners(owners);
|
||||
}
|
||||
|
||||
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
|
||||
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
|
||||
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
|
||||
}
|
||||
|
||||
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
|
||||
editorId: current.id,
|
||||
sessionId: current.sessionId,
|
||||
sftpTabId: current.sftpTabId,
|
||||
hostId: current.hostId,
|
||||
remotePath: current.remotePath,
|
||||
content: current.content,
|
||||
});
|
||||
if (result?.ok) {
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({
|
||||
editorId: tabId,
|
||||
dirty: editorTabStore.isDirty(tabId),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
const error = result?.error || "Save failed";
|
||||
editorTabStore.setSavingState(tabId, "error", error);
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return { success: false, error: "Editor tab closed" };
|
||||
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
|
||||
// Keep the snapshot's owner read-only until receipt. Success stays busy
|
||||
// until the caller removes this copy; failure restores its previous state.
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
let accepted = false;
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
|
||||
toEditorWindowSnapshot(tab, tab.hostLabel),
|
||||
);
|
||||
if (result?.success) {
|
||||
accepted = true;
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result?.error || "Failed to dock editor tab" };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
|
||||
} finally {
|
||||
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
|
||||
}
|
||||
|
||||
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
|
||||
if (editorIds.length === 0) return;
|
||||
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowCloseTabs(
|
||||
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
|
||||
): () => void {
|
||||
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
|
||||
}
|
||||
|
||||
export function reportEditorWindowCloseTabsResult(
|
||||
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
|
||||
): void {
|
||||
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
} catch {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installEditorWindowSourceListeners(): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) return () => {};
|
||||
|
||||
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
|
||||
try {
|
||||
const tab = editorTabStore.getTab(payload.editorId);
|
||||
if (!tab) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: "Editor tab closed before save completed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tab.sessionId !== payload.sessionId) {
|
||||
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
|
||||
}
|
||||
const ok = await saveEditorTab(payload.editorId, payload.content);
|
||||
const latest = editorTabStore.getTab(payload.editorId);
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok,
|
||||
liveConnectionId: latest?.sessionId,
|
||||
error: ok ? undefined : (latest?.saveError || "Save failed"),
|
||||
});
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Save failed",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
|
||||
try {
|
||||
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
|
||||
activeTabStore.setActiveTabId(toEditorTabId(id));
|
||||
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowDockResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to dock",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
|
||||
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
|
||||
for (const editorId of payload.editorIds ?? []) {
|
||||
editorTabStore.close(editorId);
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
return () => {
|
||||
unsubSave();
|
||||
unsubDock();
|
||||
unsubDirty();
|
||||
unsubClosed();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type EditorTabPlacement = "tab" | "window";
|
||||
|
||||
export interface EditorWindowTabSnapshot {
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: unknown;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveRequest {
|
||||
requestId: string;
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveResult {
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
liveConnectionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsRequest {
|
||||
requestId: string;
|
||||
editorIds: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsResult {
|
||||
requestId: string;
|
||||
cancelled: boolean;
|
||||
closedIds: string[];
|
||||
}
|
||||
@@ -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: () => {},
|
||||
};
|
||||
@@ -2,15 +2,26 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatSessionClosedExitMessage,
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
test("backend exited events close the session tab only when auto-close is enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
|
||||
// The fallback for callers that have not loaded the terminal setting yet:
|
||||
// keep the tab so a jump host / bastion reaping a session (a clean exit)
|
||||
// never silently deletes the user's scrollback.
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -56,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative exit-signal disconnects keep the tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
|
||||
});
|
||||
|
||||
test("disabled auto-close keeps command and attached terminal popups open", () => {
|
||||
@@ -120,3 +139,65 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages show the reported code for a real remote exit", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
|
||||
"\r\n[session closed (code 0)]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
|
||||
"\r\n[session closed (code 127)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "closed",
|
||||
remoteClosedWithoutExitStatus: true,
|
||||
}),
|
||||
"\r\n[session closed by remote (no exit status)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed" }),
|
||||
"\r\n[session closed]",
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,10 +4,31 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
|
||||
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
|
||||
// terminated the session process. Distinct from a transport "closed".
|
||||
exitSignal?: string;
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
remoteClosedWithoutExitStatus?: boolean;
|
||||
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
@@ -23,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
autoCloseOnExit = true,
|
||||
// Keep-tab is the safe default: a caller that has not loaded the terminal
|
||||
// setting yet must not silently delete a session tab. Remote graceful
|
||||
// channel closes (e.g. a jump host reaping an idle session) are reported
|
||||
// as a clean exit, so an unavailable setting must not re-enable closing.
|
||||
autoCloseOnExit = false,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runAutomationScript,
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
function installPermissionMode(mode: string) {
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const writeScript: Snippet = {
|
||||
id: 'write-script',
|
||||
label: 'Write',
|
||||
command: "await nct.screen.sendLine('echo hi');",
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'user-run', runIds: ['user-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
const result = await runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-user',
|
||||
initiatedBy: 'user',
|
||||
});
|
||||
assert.equal(result.runId, 'user-run');
|
||||
assert.equal(seenMode, 'auto');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'automatic-run', runIds: ['automatic-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks an AI write script in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'ai-run', runIds: ['ai-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-ai',
|
||||
initiatedBy: 'ai',
|
||||
}),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'ai-read', runIds: ['ai-read'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet: {
|
||||
id: 'read-script',
|
||||
label: 'Read',
|
||||
command: "await nct.screen.waitForPrompt(1000);",
|
||||
kind: 'script',
|
||||
},
|
||||
sessionId: 'sess-ai-read',
|
||||
initiatedBy: 'ai',
|
||||
});
|
||||
assert.equal(seenMode, 'observer');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
|
||||
initiatedBy?: 'user' | 'ai';
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createHostTerminalSession } from "./sessionFactories.ts";
|
||||
import { createLocalTerminalSession } from "./sessionFactories.ts";
|
||||
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
|
||||
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "local-vault-1",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
group: "Projects/A",
|
||||
tags: ["local"],
|
||||
os: "linux",
|
||||
protocol: "local",
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
|
||||
const host = localVaultHost({
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/work"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localShellIcon: "ubuntu",
|
||||
localStartDir: "/work",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", host);
|
||||
|
||||
assert.equal(session.protocol, "local");
|
||||
assert.equal(session.hostId, "local-vault-1");
|
||||
assert.equal(session.localShell, "wsl-ubuntu");
|
||||
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
|
||||
assert.equal(session.localShellName, "Ubuntu (WSL)");
|
||||
assert.equal(session.localShellIcon, "ubuntu");
|
||||
assert.equal(session.localStartDir, "/work");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
|
||||
const host: Host = {
|
||||
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
|
||||
} as Host;
|
||||
const session = createHostTerminalSession("session-2", host);
|
||||
|
||||
assert.equal(session.protocol, "ssh");
|
||||
assert.equal(session.localShell, undefined);
|
||||
assert.equal(session.localShellName, undefined);
|
||||
assert.equal(session.shellType, undefined);
|
||||
});
|
||||
|
||||
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
|
||||
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
|
||||
assert.equal(cmd.shellType, "cmd");
|
||||
|
||||
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
|
||||
assert.equal(pwsh.shellType, "powershell");
|
||||
|
||||
// Discovered shell IDs persisted by the local-shell editor are not
|
||||
// executable basenames, but they still resolve to POSIX shells at spawn
|
||||
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
|
||||
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
|
||||
assert.equal(wslId.shellType, "posix");
|
||||
|
||||
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
|
||||
assert.equal(gitBash.shellType, "posix");
|
||||
|
||||
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
|
||||
assert.equal(cygwin.shellType, "posix");
|
||||
|
||||
// Unix discovery emits disambiguated path-slug IDs when multiple
|
||||
// installations share a basename (e.g. /bin/bash vs
|
||||
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
|
||||
// the session must classify as that command's shell type, not "unknown".
|
||||
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
|
||||
assert.equal(slugBash.shellType, "posix");
|
||||
|
||||
const slugHomebrewBash = createHostTerminalSession(
|
||||
"session-slug-homebrew",
|
||||
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
|
||||
);
|
||||
assert.equal(slugHomebrewBash.shellType, "posix");
|
||||
|
||||
const customPowershellPath = createHostTerminalSession(
|
||||
"session-pwsh-path",
|
||||
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
|
||||
);
|
||||
assert.equal(customPowershellPath.shellType, "powershell");
|
||||
|
||||
// Default-shell hosts (empty localShell) launch the machine's OS default
|
||||
// shell (the backend's own detection), so classification must use the
|
||||
// runtime platform — not the saved host.os, which may describe a machine
|
||||
// the host was synced/imported from. Node 22's navigator.userAgent is
|
||||
// always "Node.js/<version>", so this test environment always classifies
|
||||
// as POSIX regardless of the CI runner's real OS.
|
||||
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
|
||||
assert.equal(windowsSyncedDefault.shellType, "posix");
|
||||
|
||||
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
|
||||
assert.equal(posixDefault.shellType, "posix");
|
||||
|
||||
const unknownCustom = createHostTerminalSession(
|
||||
"session-unknown",
|
||||
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
|
||||
);
|
||||
assert.equal(unknownCustom.shellType, "unknown");
|
||||
});
|
||||
|
||||
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
|
||||
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
|
||||
assert.equal(session.hostId, "local-terminal");
|
||||
assert.equal(session.localShellName, "CMD");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
|
||||
// Legacy vault hosts can predate localShellArgs, so the field may be
|
||||
// absent on the Host; the session snapshot must still distinguish
|
||||
// "launched with no args" ([]) from "no metadata" (undefined).
|
||||
const host = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-noargs", host);
|
||||
|
||||
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
|
||||
assert.deepEqual(session.localShellArgs, []);
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
|
||||
const launchTimeHost = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", launchTimeHost);
|
||||
|
||||
// The saved host was later edited to a shell with args; the snapshot taken
|
||||
// at launch time must win so the clone relaunches CMD, not WSL.
|
||||
const editedHost: Host = {
|
||||
...launchTimeHost,
|
||||
label: "Edited WSL",
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/elsewhere"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localStartDir: "/elsewhere",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [editedHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
|
||||
// The saved host has no directory of its own, so the starter launches in
|
||||
// Settings > Terminal's localStartDir. That effective directory has to be
|
||||
// recorded now; overlay falls back to the host or the current setting when
|
||||
// the session field is missing.
|
||||
const saved = localVaultHost({
|
||||
localShell: "bash",
|
||||
localShellName: "Bash",
|
||||
localStartDir: "",
|
||||
});
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
const session = createHostTerminalSession("session-inherit", effective);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
|
||||
const changedSettings = {
|
||||
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
|
||||
};
|
||||
const afterSettingsChange = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [saved],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
|
||||
|
||||
const afterHostEdit = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
|
||||
const saved = localVaultHost();
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
|
||||
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
|
||||
});
|
||||
const session = createHostTerminalSession("session-default-shell", effective);
|
||||
|
||||
assert.equal(session.localShell, "/bin/bash");
|
||||
assert.deepEqual(session.localShellArgs, ["-l"]);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
|
||||
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
|
||||
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedExplicit.localStartDir, "/work");
|
||||
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
|
||||
|
||||
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
|
||||
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedSsh.localStartDir, undefined);
|
||||
assert.equal(resolvedSsh, ssh);
|
||||
});
|
||||
|
||||
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
|
||||
const saved = localVaultHost({ username: "" });
|
||||
const ordinary = createLocalTerminalSession("ordinary-local");
|
||||
const opened = createHostTerminalSession("saved-local", saved);
|
||||
assert.equal(opened.username, ordinary.username);
|
||||
assert.equal(opened.username, "local");
|
||||
assert.equal(saved.username, "");
|
||||
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
|
||||
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
|
||||
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
|
||||
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
|
||||
});
|
||||
@@ -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;/);
|
||||
@@ -209,3 +210,13 @@ test("restore terminal cwd setting participates in cross-window settings sync",
|
||||
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
});
|
||||
|
||||
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
|
||||
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
|
||||
assert.ok(expression, "renderer settings options must be explicit");
|
||||
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
|
||||
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
|
||||
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
|
||||
assert.equal(resolveOptions(false, false), undefined);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -520,3 +520,24 @@ test("folder resume releases a completed child compacted while pause was drainin
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
|
||||
|
||||
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
|
||||
const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch");
|
||||
const { bumpTransferControlEpoch } = await import("./transferControlEpoch");
|
||||
const { releaseTransferPauseTree } = await import("./transferPauseLatch");
|
||||
t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); });
|
||||
let finishPause!: (value: { success: boolean }) => void;
|
||||
let resumes = 0;
|
||||
const { host } = createHost([makeTask("cancel-during-pause")], () => ({
|
||||
pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }),
|
||||
resumeTransfer: async () => { resumes += 1; return { success: true }; },
|
||||
}));
|
||||
const pause = softPauseTransfer(host, "cancel-during-pause");
|
||||
markTransferCancelledTree("cancel-during-pause");
|
||||
bumpTransferControlEpoch("cancel-during-pause");
|
||||
releaseTransferPauseTree("cancel-during-pause");
|
||||
finishPause({ success: true });
|
||||
await pause;
|
||||
assert.equal(resumes, 0);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
import { isTransferOrRootCancelled } from "./transferCancelLatch";
|
||||
import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
|
||||
export type TransferControlBridge = {
|
||||
@@ -72,7 +73,7 @@ export function reconcileSupersededControls(
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) return;
|
||||
const task = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
|
||||
@@ -234,7 +235,7 @@ export async function softPauseTransfer(
|
||||
const undoObsoletePause = async (id: string) => {
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint";
|
||||
|
||||
/** Apply only after backend cancellation AND the old walk have settled. */
|
||||
export function settleCancelledTransferTree(
|
||||
tasks: readonly TransferTask[],
|
||||
rootId: string,
|
||||
cancelledIds: ReadonlySet<string>,
|
||||
failedIds: ReadonlySet<string>,
|
||||
retainedTasks: readonly TransferTask[] = [],
|
||||
): TransferTask[] {
|
||||
const existingTasks = new Map(tasks.map((task) => [task.id, task]));
|
||||
// Use observed terminal outcomes rather than stale pre-cancel snapshots.
|
||||
// Missing completed children already covered by the parent stay compacted;
|
||||
// cancelled/failed exceptions still need their rows for the recovery walk.
|
||||
const settledTasks = failedIds.size > 0
|
||||
? [...tasks, ...retainedTasks.filter((task) => {
|
||||
if (existingTasks.has(task.id)) return false;
|
||||
const checkpoint = task.parentTaskId
|
||||
? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint
|
||||
: undefined;
|
||||
return !(task.status === "completed"
|
||||
&& isValidDirectoryResumeCheckpoint(checkpoint)
|
||||
&& Number.isSafeInteger(task.directoryEntryIndex)
|
||||
&& (task.directoryEntryIndex ?? -1) >= 0
|
||||
&& task.directoryEntryIndex! < checkpoint.coveredEntries);
|
||||
})]
|
||||
: tasks;
|
||||
return settledTasks.map((task) => {
|
||||
// Natural completion/failure wins a race with Cancel. Rows cancelled by
|
||||
// this attempt can be re-admitted only when the whole tree needs recovery.
|
||||
if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task;
|
||||
const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0);
|
||||
const recoverable = failedIds.size > 0;
|
||||
return {
|
||||
...task,
|
||||
status: failed ? "attention" : recoverable ? "interrupted" : "cancelled",
|
||||
error: failed ? "Could not cancel transfer. Please try again." : undefined,
|
||||
reconnectRequired: recoverable,
|
||||
endTime: recoverable ? undefined : Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
conflict: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch";
|
||||
import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime";
|
||||
import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry";
|
||||
|
||||
const task = (id: string, extra: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`,
|
||||
sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload",
|
||||
status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true, ...extra,
|
||||
});
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-"));
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(async () => {
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const store = createSftpTransferCenterStore();
|
||||
const runtime = createTransferRuntime(store);
|
||||
const ids = ["active", "queued-1", "queued-2"];
|
||||
const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`));
|
||||
const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) }));
|
||||
store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
t.after(() => release());
|
||||
let softResumeCalls = 0;
|
||||
let pauseCalls = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => ({ success: id !== "active" }),
|
||||
pauseTransfer: async () => { pauseCalls += 1; return { success: true }; },
|
||||
clearPendingTransferCancel: async () => undefined,
|
||||
resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; },
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
let starts = 0;
|
||||
const walking = runtime.runWalk("folder", async () => {
|
||||
await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run(
|
||||
"closed-panel", child.id, ["cancel-fixture"], () => 1, async () => {
|
||||
starts += 1;
|
||||
await held;
|
||||
await writeFile(child.targetPath, expected[index]);
|
||||
store.patchTask(child.id, { status: "completed" });
|
||||
},
|
||||
).catch(() => store.patchTask(child.id, { status: "cancelled" }))));
|
||||
store.patchTask("folder", { status: "cancelled" });
|
||||
});
|
||||
await tick();
|
||||
assert.equal(starts, 1);
|
||||
let cancelSettled = false;
|
||||
const cancelling = store.cancel("folder").then(() => { cancelSettled = true; });
|
||||
const resuming = store.resume("folder");
|
||||
await tick();
|
||||
assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry");
|
||||
assert.equal(store.getTask("folder")?.status, "attention");
|
||||
assert.equal(isTransferOrRootCancelled("folder"), true);
|
||||
assert.equal(softResumeCalls, 0, "must not unlatch the old walk");
|
||||
await store.pause("folder");
|
||||
assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk");
|
||||
let freshWalks = 0;
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
freshWalks += 1;
|
||||
assert.equal(runtime.isWalkInFlight("folder"), false);
|
||||
for (let index = 0; index < children.length; index += 1) {
|
||||
const child = children[index];
|
||||
if (store.getTask(child.id)?.status === "completed") continue;
|
||||
assert.equal(store.admitTaskRun(child), "ready");
|
||||
await writeFile(child.targetPath, expected[index]);
|
||||
store.patchTask(child.id, { status: "completed" });
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
release();
|
||||
await Promise.all([walking, cancelling, resuming]);
|
||||
assert.equal(freshWalks, 1);
|
||||
assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files");
|
||||
assert.equal(store.getTask("folder")?.status, "completed");
|
||||
for (let index = 0; index < children.length; index += 1) {
|
||||
assert.deepEqual(await readFile(children[index].targetPath), expected[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed ownerless compression cancellation retries the same backend", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelCompressedUpload: async (id: string) => {
|
||||
calls.push(`compressed:${id}`);
|
||||
return { success: calls.length > 1 };
|
||||
},
|
||||
cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; },
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]);
|
||||
await store.cancel("archive");
|
||||
assert.equal(store.getTask("archive")?.status, "attention");
|
||||
await store.cancel("archive");
|
||||
assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]);
|
||||
assert.equal(store.getTask("archive")?.status, "cancelled");
|
||||
});
|
||||
|
||||
test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]);
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
calls.push(id);
|
||||
store.patchTask(id, { status: "completed" });
|
||||
return { success: true };
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("selected");
|
||||
assert.deepEqual(calls, ["selected"]);
|
||||
assert.equal(store.getTask("selected")?.status, "completed");
|
||||
assert.equal(store.getTask("peer")?.status, "paused");
|
||||
});
|
||||
|
||||
|
||||
test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => {
|
||||
const { markTransferCancelledTree } = await import("./transferCancelLatch");
|
||||
t.after(resetTransferCancelLatchesForTests);
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]);
|
||||
markTransferCancelledTree("waiting-cancel-batch");
|
||||
await store.resume("waiting-cancel-batch");
|
||||
assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused");
|
||||
assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true);
|
||||
});
|
||||
|
||||
|
||||
test("failed cancel can be retried while its old walk is still running", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const runtime = createTransferRuntime(store);
|
||||
store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
t.after(() => release());
|
||||
const walking = runtime.runWalk("retry-live-cancel", async () => { await held; });
|
||||
let attempts = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return { success: false };
|
||||
release();
|
||||
return { success: true };
|
||||
},
|
||||
cleanupTransferArtifacts: async () => undefined,
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("retry-live-cancel");
|
||||
assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true);
|
||||
assert.equal(store.getTask("retry-live-cancel")?.status, "attention");
|
||||
const resume = store.resume("retry-live-cancel");
|
||||
await store.cancel("retry-live-cancel");
|
||||
await Promise.all([walking, resume]);
|
||||
assert.equal(attempts, 2, "second cancel must reach the backend before natural completion");
|
||||
assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome");
|
||||
});
|
||||
|
||||
|
||||
test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }),
|
||||
...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let cleared = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }),
|
||||
clearPendingTransferCancel: async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
cleared += 1;
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("cleanup-root");
|
||||
assert.equal(cleared, 2201);
|
||||
assert.ok(peak > 1, "recovery must not serialize one IPC per row");
|
||||
assert.ok(peak <= 32, "recovery uses the cancellation batch bound");
|
||||
});
|
||||
|
||||
for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) {
|
||||
test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint");
|
||||
const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } =
|
||||
await import("./dedicatedTransferResume");
|
||||
const originalGet = netcattyBridge.get;
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const parent = task(`compacted-cancel-${completeDuring}`, {
|
||||
sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host",
|
||||
isDirectory: true, progressMode: "files", totalBytes: 3,
|
||||
});
|
||||
const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => {
|
||||
const child = task(`${parent.id}-${name}`, {
|
||||
parentTaskId: parent.id, fileName: name,
|
||||
sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`,
|
||||
sourceLastModified: 1, directoryEntryIndex: index,
|
||||
});
|
||||
return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({
|
||||
sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1,
|
||||
}) };
|
||||
});
|
||||
// Match the sorted traversal order so dedicated resume validates the actual checkpoint.
|
||||
children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
||||
children.forEach((child, index) => { child.directoryEntryIndex = index; });
|
||||
const completed = children.find((child) => child.fileName === "completed")!;
|
||||
const cancelled = children.find((child) => child.fileName === "cancelled")!;
|
||||
const failed = children.find((child) => child.fileName === "cancel-failed")!;
|
||||
const runtime = createTransferRuntime(store);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let walking: Promise<unknown> | undefined;
|
||||
t.after(async () => {
|
||||
release();
|
||||
await walking;
|
||||
store.setDedicatedResumeHandler(null);
|
||||
store.patchTask(parent.id, { status: "completed" });
|
||||
store.dismiss(parent.id);
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
const uploads: string[] = [];
|
||||
const progress: number[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
if (completeDuring === "backend-cancel" && id === completed.id) {
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 });
|
||||
}
|
||||
return { success: id !== failed.id };
|
||||
},
|
||||
clearPendingTransferCancel: async () => undefined,
|
||||
openSftp: async () => "reconnected-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => children.map((child) => ({
|
||||
localPath: child.sourcePath, relativePath: child.fileName,
|
||||
type: "file", size: 8, lastModified: 1,
|
||||
})),
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => ({ size: 8, lastModified: 1 }),
|
||||
startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => {
|
||||
if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4);
|
||||
uploads.push(options.sourcePath);
|
||||
return {};
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.publishOwner("closed-panel", [parent, ...children]);
|
||||
walking = runtime.runWalk(parent.id, async () => {
|
||||
await held;
|
||||
if (completeDuring === "walk-settlement") {
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 });
|
||||
}
|
||||
store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 });
|
||||
store.patchTask(parent.id, { status: "cancelled" });
|
||||
});
|
||||
await store.cancel(parent.id);
|
||||
assert.equal(runtime.isWalkInFlight(parent.id), true);
|
||||
store.setDedicatedResumeHandler(async (root) => {
|
||||
assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted");
|
||||
assert.equal(store.getTask(cancelled.id)?.status, "interrupted");
|
||||
assert.equal(store.getTask(failed.id)?.status, "attention");
|
||||
return resumeTransferWithDedicatedSession(root, {
|
||||
hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22,
|
||||
username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }],
|
||||
keys: [], identities: [],
|
||||
}, (value) => { progress.push(value.transferred); }, {
|
||||
children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id),
|
||||
onChildUpdate: (child) => { store.upsertTasks([child]); },
|
||||
onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }),
|
||||
});
|
||||
});
|
||||
const resuming = store.resume(parent.id);
|
||||
release();
|
||||
await Promise.all([walking, resuming]);
|
||||
assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(),
|
||||
"only cancelled and failed-to-cancel children should upload again");
|
||||
assert.equal(store.getTask(parent.id)?.status, "completed");
|
||||
assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once");
|
||||
assert.equal(progress.at(-1), 3);
|
||||
});
|
||||
}
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelCompressedUpload: async () => {
|
||||
if (rejects) throw new Error("Cancel IPC failed");
|
||||
return { success: false };
|
||||
},
|
||||
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
|
||||
resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; },
|
||||
clearPendingTransferCancel: async () => {},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
|
||||
store.publishOwner("closed-panel", [task("live-compressed", {
|
||||
isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
|
||||
})]);
|
||||
await store.cancel("live-compressed");
|
||||
assert.equal(store.getTask("live-compressed")?.status, "attention");
|
||||
await store.resume("live-compressed");
|
||||
assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer");
|
||||
assert.equal(store.getTask("live-compressed")?.status, "transferring");
|
||||
});
|
||||
}
|
||||
|
||||
for (const hasConflict of [false, true]) {
|
||||
test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => {
|
||||
const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter");
|
||||
const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions");
|
||||
const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration");
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const saved = task("restored-compressed", {
|
||||
status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
|
||||
error: "Could not cancel the compressed upload.",
|
||||
...(hasConflict ? { conflict: {
|
||||
transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder",
|
||||
isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1,
|
||||
} } : {}),
|
||||
});
|
||||
const raw = serializeSftpTransferCenter([saved]);
|
||||
const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} });
|
||||
const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] }));
|
||||
assert.equal(restored.getTask(saved.id)?.reconnectRequired, true);
|
||||
assert.equal(cooperative.tasks[0]?.reconnectRequired, true);
|
||||
assert.equal(restored.getTask(saved.id)?.status, "attention");
|
||||
assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict);
|
||||
assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row");
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
|
||||
clearPendingTransferCancel: async () => {},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
|
||||
const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks);
|
||||
assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]);
|
||||
for (const id of resumeIds) await restored.resume(id);
|
||||
assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]);
|
||||
});
|
||||
}
|
||||
@@ -972,13 +972,10 @@ export function useSftpDirectoryTransferOps({
|
||||
if (isTransferCancelledError(err)) {
|
||||
// Keep cancelled status; do not rethrow — other workers must finish
|
||||
// and the parent should not become a clean completed tree.
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === fileId
|
||||
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
setTransfers((prev) => prev.map((task) =>
|
||||
task.id === fileId
|
||||
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: task));
|
||||
errors.push(err instanceof Error ? err : new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TransferRuntime {
|
||||
* walk is dead / reconnectRequired. Single external operation.
|
||||
*/
|
||||
resume(taskId: string): Promise<void>;
|
||||
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user