mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba62febbd | ||
|
|
f0897c3c17 | ||
|
|
8d826594d3 | ||
|
|
a4999fdf1d | ||
|
|
e907336ef5 | ||
|
|
58c6a216ac | ||
|
|
eb2c4b1611 | ||
|
|
392a094297 | ||
|
|
f300d583b2 | ||
|
|
70d58a0cad | ||
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c | ||
|
|
0256e7471d | ||
|
|
a4b1009c2a | ||
|
|
54b6f85b09 | ||
|
|
62798393c3 | ||
|
|
d382f7bd4f | ||
|
|
ff36dc7bce | ||
|
|
d45fb75370 | ||
|
|
5a1b50a407 | ||
|
|
0e7ba69edb | ||
|
|
babf97e2c1 | ||
|
|
622ffec2cd | ||
|
|
598de9cda0 | ||
|
|
3c1afa9b7d | ||
|
|
36d9a7bd7f | ||
|
|
c44633231a | ||
|
|
b1a13011a4 | ||
|
|
631ce6c1d5 | ||
|
|
3e9ac6a36b | ||
|
|
cbb1262955 | ||
|
|
7dcb52062f | ||
|
|
2f1cc57c54 | ||
|
|
c54df5e7d5 | ||
|
|
bb1d1ce8f7 | ||
|
|
e9e4ae576f | ||
|
|
2b0337bb55 | ||
|
|
dee7912f8c | ||
|
|
161c895240 | ||
|
|
d2ce41cf1c | ||
|
|
857d20db88 | ||
|
|
43d714b73c | ||
|
|
964e225f0f | ||
|
|
40ad9b2fff | ||
|
|
b83a0459f3 | ||
|
|
58418e887a | ||
|
|
3e68f7cb3e | ||
|
|
520c99da77 | ||
|
|
7eeb3d5724 | ||
|
|
ed763c89b3 | ||
|
|
b884eff429 | ||
|
|
ade10ee985 | ||
|
|
cbcec92d32 | ||
|
|
5ded1f5308 | ||
|
|
a3b3e70388 | ||
|
|
e997d52418 | ||
|
|
e9818a619c | ||
|
|
02f8c798f7 | ||
|
|
45b0673ce8 | ||
|
|
f7daed70ee | ||
|
|
651d474e01 | ||
|
|
6e12c314ec | ||
|
|
97dfab225e | ||
|
|
90b6569c7a | ||
|
|
695fd27688 | ||
|
|
fe7dbbf6b9 | ||
|
|
238fe45ee4 | ||
|
|
8afaf60c50 | ||
|
|
88a2019518 | ||
|
|
e780d44f97 | ||
|
|
9766f21ecf | ||
|
|
cb27b3d01b | ||
|
|
e9cd311936 | ||
|
|
d6871dbca9 | ||
|
|
850c5309b6 | ||
|
|
e5bfc12a2e | ||
|
|
6ab684bcd5 | ||
|
|
acecd46d57 | ||
|
|
aee852a9ac | ||
|
|
5bef721ac9 | ||
|
|
4c82424adf | ||
|
|
439f981c67 | ||
|
|
3d593b206b | ||
|
|
f95e490707 | ||
|
|
bc36f6a085 | ||
|
|
f7a166c3e9 | ||
|
|
7c82ec15d0 | ||
|
|
2849871111 | ||
|
|
36057a97d8 | ||
|
|
41c66f76fa | ||
|
|
54cedb09b2 | ||
|
|
1956513a5e | ||
|
|
5374b981ec | ||
|
|
864b8d46eb | ||
|
|
acebaa858c | ||
|
|
e7f4a37ec7 | ||
|
|
eeaf941778 |
No files matched your search
@@ -82,6 +82,8 @@ on:
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/tray-panel-layout.live.test.cjs"
|
||||
- "scripts/serial-named-pipe.live.test.cjs"
|
||||
- "scripts/fixtures/serial/**"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
@@ -251,6 +253,10 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test Windows serial pipe through native Electron
|
||||
if: matrix.name == 'windows'
|
||||
run: npx electron scripts/serial-named-pipe.live.test.cjs
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
@@ -324,6 +330,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Test tray panel layout
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
@@ -625,6 +634,9 @@ jobs:
|
||||
done
|
||||
npm run pack:linux-x64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
@@ -759,6 +771,9 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test SSH cipher negotiation in Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
@@ -91,6 +96,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Exercise serial named pipe transport on Windows
|
||||
run: node --test electron/bridges/serialNamedPipe.test.cjs
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
@@ -135,5 +143,10 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -181,7 +181,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
|
||||
|
||||
## Monitoring View
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
|
||||
|
||||
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveHostAuth } from '../domain/sshAuth.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, Identity } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
// resolveHostAuth prefers identity.password, so the stale keychain secret
|
||||
// shadows whatever is already stored on the host.
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'stale-keychain-password',
|
||||
);
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate',
|
||||
['rotated-password'],
|
||||
'rotated-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'rotated-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, baseHost.username);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'rotated-password',
|
||||
);
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'deploy',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: 'root',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, 'deploy');
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'deploy');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'deploy');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'alice',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: '',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-blank-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-blank-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].username, 'alice');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'alice');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'alice');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,11 @@ import {
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import {
|
||||
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
|
||||
getSettingsChromeSnapshot,
|
||||
publishSettingsChromeSnapshot,
|
||||
} from './state/settingsChromeStore.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -829,3 +835,201 @@ for (const scenario of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const shiftedActions: string[] = [];
|
||||
let shiftedPrevented = false;
|
||||
let shiftedStopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
shiftedActions.push(action);
|
||||
return action === 'toggleHostTree';
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { shiftedPrevented = true; },
|
||||
stopPropagation: () => { shiftedStopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(shiftedActions, ['toggleHostTree']);
|
||||
assert.equal(shiftedPrevented, true);
|
||||
assert.equal(shiftedStopped, true);
|
||||
|
||||
const plainActions: string[] = [];
|
||||
let plainPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
plainActions.push(action);
|
||||
return undefined;
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { plainPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(plainActions, ['broadcast']);
|
||||
assert.equal(plainPrevented, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
actions.push(action);
|
||||
return false;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(actions, ['toggleHostTree']);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
|
||||
const monaco = new FakeMonacoHTMLElement();
|
||||
const monacoActions: string[] = [];
|
||||
let monacoPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
monacoActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: monaco,
|
||||
composedPath: () => [monaco],
|
||||
preventDefault: () => { monacoPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(monacoActions, ['toggleHostTree']);
|
||||
assert.equal(monacoPrevented, true);
|
||||
|
||||
const input = new FakeInputHTMLElement();
|
||||
const inputActions: string[] = [];
|
||||
let inputPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
inputActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: input,
|
||||
composedPath: () => [input],
|
||||
preventDefault: () => { inputPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(inputActions, []);
|
||||
assert.equal(inputPrevented, false);
|
||||
});
|
||||
|
||||
test('toggleHostTree uses the host list toggle only while that list can show', () => {
|
||||
const previous = getSettingsChromeSnapshot();
|
||||
const calls: string[] = [];
|
||||
const toggleHostTree = () => { calls.push('toggle'); };
|
||||
const event = {} as KeyboardEvent;
|
||||
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
editorTabs: [],
|
||||
orderedTabs,
|
||||
sessions: [{ id: 'session-1' }],
|
||||
settings: {},
|
||||
toEditorTabId: (id: string) => id,
|
||||
toggleHostTree,
|
||||
workspaces: [{ id: 'workspace-1' }],
|
||||
});
|
||||
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
|
||||
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test('host list shortcut calls the same store toggle as the toolbar button', () => {
|
||||
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
|
||||
import { isHostTreeWorkTabSurface } from './workTabSurface';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
@@ -276,6 +278,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
||||
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
||||
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
||||
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
|
||||
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
|
||||
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
|
||||
|
||||
if (
|
||||
(isFormElement || isMonacoElement)
|
||||
@@ -283,6 +288,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
&& e.key !== 'Escape'
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !(isMonacoElement && isHostTreeHotkey)
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
@@ -322,9 +328,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unavailable broadcast action must leave the chord to the terminal
|
||||
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast') {
|
||||
// An unavailable action must leave the chord to the terminal.
|
||||
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
|
||||
// when the sidebar cannot show. Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
|
||||
if (executeHotkeyAction(binding.action, e) === true) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -386,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
const {
|
||||
hosts,
|
||||
hostsRef,
|
||||
identities,
|
||||
keyboardInteractiveQueue,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
@@ -417,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
// Save password to host if requested - never for second-factor / EDR prompts
|
||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||
// host login password (#2150 / Codex review on #2151).
|
||||
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
|
||||
// identity.password and identity.username, so snapshot the identity username
|
||||
// onto the host before clearing identityId (#3982). Same as
|
||||
// applyVaultHostUpdate's password detach. Do not mutate the identity.
|
||||
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
} : h));
|
||||
updateHosts(latestHosts.map((h: Host) => {
|
||||
if (h.id !== host.id) return h;
|
||||
const identity = h.identityId
|
||||
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
|
||||
: undefined;
|
||||
const detachedUsername = identity?.username?.trim();
|
||||
return {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
...(h.identityId ? {
|
||||
identityId: '',
|
||||
...(detachedUsername ? { username: detachedUsername } : {}),
|
||||
} : {}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
@@ -801,7 +824,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, toggleWindowOpacity, 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, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -997,6 +1020,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'toggleHostTree': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!isHostTreeWorkTabSurface({
|
||||
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
|
||||
activeTabId: currentId,
|
||||
orderedTabs: orderedTabs ?? [],
|
||||
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
|
||||
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
|
||||
}) || typeof toggleHostTree !== 'function') return false;
|
||||
toggleHostTree();
|
||||
return true;
|
||||
}
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
@@ -1145,6 +1180,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1164,6 +1200,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1184,7 +1221,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -1200,7 +1237,10 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
if (matchingLog) {
|
||||
updateConnectionLog(matchingLog.id, {
|
||||
// The IndexedDB replay write commits asynchronously. Waiting here keeps
|
||||
// a crash during session teardown from dropping the newest capture any
|
||||
// more often than the old synchronous localStorage write.
|
||||
await updateConnectionLog(matchingLog.id, {
|
||||
endTime: Date.now(),
|
||||
terminalData: data,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ConnectionLog, Host } from '../../domain/models';
|
||||
import { createLocalShellHost } from '../../domain/localShellHost';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
|
||||
|
||||
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
|
||||
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
|
||||
const connectionLogs: ConnectionLog[] = [];
|
||||
let appendedHost: Host | undefined;
|
||||
const getCtx = () => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
|
||||
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
|
||||
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
|
||||
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
|
||||
connectToHost: (candidate: Host) => {
|
||||
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
|
||||
appendedHost = candidate;
|
||||
return 'workspace-session';
|
||||
},
|
||||
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
|
||||
connectionLogs.push({ ...entry, id: 'workspace-log' });
|
||||
},
|
||||
});
|
||||
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
|
||||
assert.equal(appendedHost?.localShell, '/bin/zsh');
|
||||
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
|
||||
assert.equal(appendedHost?.localStartDir, '/tmp/work');
|
||||
assert.equal(connectionLogs.length, 1);
|
||||
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
|
||||
assert.equal(connectionLogs[0].hostId, host.id);
|
||||
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
|
||||
assert.equal(connectionLogs[0].username, 'local-user');
|
||||
assert.equal(connectionLogs[0].protocol, 'local');
|
||||
assert.equal(connectionLogs[0].localHostname, 'local-machine');
|
||||
const updates: Array<{ id: string; terminalData: string }> = [];
|
||||
await handleTerminalDataCaptureImpl(() => ({
|
||||
IS_DEV: false,
|
||||
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
|
||||
// A newer, same-host log must not steal this session's capture.
|
||||
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
|
||||
selectConnectionLogForTerminalDataCapture,
|
||||
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
|
||||
}), 'workspace-session', 'captured terminal output');
|
||||
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
|
||||
});
|
||||
|
||||
for (const protocol of ['local', 'ssh', 'serial'] as const) {
|
||||
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
|
||||
let logs = 0;
|
||||
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
|
||||
const sessionId = handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
|
||||
connectToHost: () => null,
|
||||
addConnectionLog: () => { logs += 1; },
|
||||
}), host);
|
||||
assert.equal(sessionId, null);
|
||||
assert.equal(logs, 0);
|
||||
});
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import {
|
||||
markExternalMcpStartupReady,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
@@ -254,6 +256,7 @@ export function AppSideEffects() {
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
@@ -713,7 +716,7 @@ export function AppSideEffects() {
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
||||
@@ -1126,6 +1129,7 @@ export function AppSideEffects() {
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleHostTree: () => terminalHostTreeStore.toggle(),
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
@@ -1456,7 +1460,7 @@ export function AppSideEffects() {
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
@@ -1474,7 +1478,9 @@ export function AppSideEffects() {
|
||||
: resolveEffectiveHost;
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
connectToHost: workspaceId
|
||||
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
|
||||
: connectToHost,
|
||||
discoveredShells,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
@@ -1485,7 +1491,7 @@ export function AppSideEffects() {
|
||||
systemInfoRef,
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1495,12 +1501,14 @@ export function AppSideEffects() {
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||
if (target.error) return { ok: false as const, error: target.error };
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
// host_open always opens a visible tab, as documented.
|
||||
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
|
||||
const sessionId = handleConnectToHost(host, true, hidden);
|
||||
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
|
||||
if (!sessionId) {
|
||||
return { ok: false as const, error: `Failed to open host "${host.id}".` };
|
||||
}
|
||||
@@ -1509,8 +1517,8 @@ export function AppSideEffects() {
|
||||
if (readExternalMcpFocusOnHostOpen()) {
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
}
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost]);
|
||||
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
|
||||
}, [handleConnectToHost, workspaces]);
|
||||
|
||||
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
|
||||
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// A separate renderer thread lets the real main-process timeout run while
|
||||
// window.confirm is synchronously blocked, just like Electron's native dialog.
|
||||
require("tsx/cjs");
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const React = require("react");
|
||||
const { act, create } = require("react-test-renderer");
|
||||
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
|
||||
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
|
||||
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
|
||||
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
|
||||
const signal = new Int32Array(workerData.signal);
|
||||
let resumes = 0;
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
global.window = {
|
||||
confirm() {
|
||||
parentPort.postMessage({ type: "prompt" });
|
||||
Atomics.wait(signal, 0, 0, 10000);
|
||||
return workerData.accept;
|
||||
},
|
||||
};
|
||||
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
|
||||
sftpTransferCenterStore.pause = async () => {};
|
||||
sftpTransferCenterStore.resume = async () => { resumes++; };
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
netcattyBridge.get = () => ({
|
||||
onCheckDirtyEditors(callback) {
|
||||
return preload.onCheckDirtyEditors((request) => {
|
||||
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
|
||||
});
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
});
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
|
||||
return null;
|
||||
}
|
||||
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
|
||||
void act(async () => { create(React.createElement(Probe)); })
|
||||
.then(() => parentPort.postMessage({ type: "ready" }));
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -70,8 +71,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -139,6 +144,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -150,8 +156,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -163,6 +173,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
@@ -181,7 +192,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +214,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 20000);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
|
||||
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
|
||||
);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects.ts';
|
||||
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
|
||||
const require = createRequire(import.meta.url);
|
||||
const React = require('react');
|
||||
const { act, create } = require('react-test-renderer');
|
||||
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
|
||||
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
|
||||
const { createPreloadApi } = require('../../electron/preload/api.cjs');
|
||||
|
||||
// No Electron UI, sockets, real transfers, or preferences. Only the network
|
||||
// pause completion is held. Actual renderer hook, window close callback, and
|
||||
// dirty-query helper run together with the actual 5000ms production deadline.
|
||||
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
|
||||
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let confirmations = 0, reports = 0, resumeCalls = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let renderer: any, releasePause: any;
|
||||
const queryPromises: Promise<void>[] = [];
|
||||
const pause = new Promise<void>(resolve => { releasePause = resolve; });
|
||||
if (!slowPause) releasePause();
|
||||
const ipcMain = new EventEmitter();
|
||||
let win: any;
|
||||
t.after(async () => {
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await act(async () => renderer?.unmount());
|
||||
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
|
||||
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
onCheckDirtyEditors(callback: any) {
|
||||
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
class Window extends EventEmitter {
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
|
||||
setWindowOpenHandler() {}, openDevTools() {},
|
||||
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
|
||||
});
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
|
||||
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
|
||||
queryDirtyEditors(wc: any, timeout: number, options: any) {
|
||||
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
|
||||
return queryDirtyEditors(wc, timeout, options);
|
||||
},
|
||||
});
|
||||
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
|
||||
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
|
||||
electronDir: '/unused/electron', registerAsMainWindow: true,
|
||||
});
|
||||
win.close();
|
||||
assert.equal(win.destroyed, false, 'first close is correctly guarded');
|
||||
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
|
||||
if (!slowPause) {
|
||||
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
|
||||
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
|
||||
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
|
||||
return;
|
||||
}
|
||||
assert.equal(confirmations, 0, 'slow pause prevents prompt');
|
||||
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
|
||||
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
|
||||
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
|
||||
assert.equal(win.destroyed, false);
|
||||
assert.equal(resumeCalls, 1);
|
||||
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, once } = require("node:events");
|
||||
const { Worker } = require("node:worker_threads");
|
||||
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
|
||||
const signal = new SharedArrayBuffer(4);
|
||||
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
|
||||
workerData: { accept, signal },
|
||||
});
|
||||
const ipcMain = new EventEmitter();
|
||||
const events = new EventEmitter();
|
||||
const replies = [];
|
||||
let promptShown = false;
|
||||
let closed = false;
|
||||
const wc = {
|
||||
isDestroyed: () => closed,
|
||||
isCrashed: () => false,
|
||||
send(channel, payload) { worker.postMessage({ channel, payload }); },
|
||||
};
|
||||
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
|
||||
worker.on("message", (message) => {
|
||||
if (message.type === "reply") {
|
||||
replies.push(message.payload);
|
||||
ipcMain.emit(message.channel, { sender: wc }, message.payload);
|
||||
} else {
|
||||
if (message.type === "prompt") promptShown = true;
|
||||
events.emit(message.type, message);
|
||||
}
|
||||
});
|
||||
worker.on("error", (error) => events.emit("error", error));
|
||||
await once(events, "ready");
|
||||
const started = Date.now();
|
||||
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
if (!result) closed = true;
|
||||
assert.equal(promptShown, true);
|
||||
assert.equal(result, "transfers");
|
||||
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
|
||||
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
|
||||
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
|
||||
const done = once(events, "done");
|
||||
Atomics.store(new Int32Array(signal), 0, 1);
|
||||
Atomics.notify(new Int32Array(signal), 0);
|
||||
const [completed] = await done;
|
||||
assert.equal(completed.resumes, 1);
|
||||
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
|
||||
assert.equal(replies.at(-1).reason, "transfers");
|
||||
assert.equal(closed, false);
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
|
||||
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
totalBytes: 10,
|
||||
transferredBytes: 2,
|
||||
speed: 1,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
@@ -13,7 +30,8 @@ for (const accept of [false, true]) {
|
||||
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
|
||||
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
|
||||
if (!g.window) g.window = {};
|
||||
g.window.confirm = () => accept;
|
||||
let prompts = 0;
|
||||
g.window.confirm = () => { prompts++; return accept; };
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let releaseResume!: () => void;
|
||||
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||
@@ -76,12 +94,159 @@ for (const accept of [false, true]) {
|
||||
}
|
||||
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
|
||||
if (accept) {
|
||||
assert.deepEqual(reports, [[false, undefined]]);
|
||||
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
|
||||
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
|
||||
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
// Dedicated resume promises can represent the whole transfer lifetime.
|
||||
// A second close must still prompt while those promises remain pending.
|
||||
await query();
|
||||
assert.equal(prompts, 2);
|
||||
assert.equal(resumeSettled, false);
|
||||
assert.equal(reports.length, 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
|
||||
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {
|
||||
confirm() { prompts++; now = 6000; return true; },
|
||||
} });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async () => {
|
||||
if (outcome === "pause-rejected") throw new Error("pause failed");
|
||||
if (outcome === "pause-expired") now = 6000;
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
|
||||
replies.push({ dirty, reason, ...options });
|
||||
},
|
||||
}));
|
||||
function Probe() {
|
||||
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(Probe)); });
|
||||
await query({ requestId: outcome, deadline: 5000 });
|
||||
assert.deepEqual(replies, [
|
||||
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
|
||||
{ dirty: true, reason: "transfers", requestId: outcome },
|
||||
]);
|
||||
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
|
||||
assert.equal(resumes, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let cancelled = false;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
function Probe() {
|
||||
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(Probe)); });
|
||||
const first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
cancelled = true;
|
||||
await query({ requestId: "after-cancel", deadline: 11000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
|
||||
const replyCount = replies.length;
|
||||
releasePause();
|
||||
await first;
|
||||
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
|
||||
assert.equal(prompts, 0);
|
||||
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
|
||||
});
|
||||
|
||||
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let status: TransferTask["status"] = "transferring";
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
function Probe() {
|
||||
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(Probe)); });
|
||||
const first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
const retry = query({ requestId: "retry", deadline: 11000 });
|
||||
status = "paused";
|
||||
releasePause();
|
||||
await Promise.all([first, retry]);
|
||||
assert.equal(prompts, 1);
|
||||
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
|
||||
});
|
||||
@@ -344,12 +344,26 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(async () => {
|
||||
const unsub = bridge.onCheckDirtyEditors(async (request) => {
|
||||
const response = { requestId: request?.requestId };
|
||||
// Another close request must not start a second pause/prompt while the
|
||||
// first attempt is still pausing or awaiting a decision.
|
||||
const previousAttempt = transferQuitPendingRef.current;
|
||||
if (previousAttempt && Date.now() < previousAttempt.deadline) {
|
||||
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
|
||||
return;
|
||||
}
|
||||
// An expired pause may never settle. Let a retry re-check current tasks
|
||||
// (including a user's cancellation), without granting the old attempt
|
||||
// permission to prompt or resume over that newer decision.
|
||||
let attempt: typeof previousAttempt = null;
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
@@ -374,24 +388,44 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
|| previousAttempt?.resumeTaskIds.has(task.id)
|
||||
));
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
if (hasDirty) {
|
||||
// The user chose to stay. Main must hear that before any resume:
|
||||
// resume can outlive the 5s dirty-check timeout, and a late reply
|
||||
// lets the app quit anyway. The synchronous dialog is also skipped
|
||||
// because this prompt was already answered.
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
// A slow pause must never turn the main-process timeout into
|
||||
// permission to quit before the user has made a decision.
|
||||
hasDirty = true;
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
// A retry inherits the still-unfinished rows the expired attempt
|
||||
// auto-paused. Their current status may already be pausing/paused.
|
||||
attempt = {
|
||||
deadline: request?.deadline ?? Date.now() + 5000,
|
||||
resumeTaskIds: new Set(resumable.map((task) => task.id)),
|
||||
};
|
||||
transferQuitPendingRef.current = attempt;
|
||||
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
|
||||
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
if (transferQuitPendingRef.current !== attempt) return;
|
||||
const expired = () => request && Date.now() >= request.deadline;
|
||||
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
// The native prompt can itself outlive the main-process deadline.
|
||||
// An expired acceptance is not permission for a later close attempt.
|
||||
if (expired()) hasDirty = true;
|
||||
}
|
||||
if (!hasDirty) {
|
||||
reason = undefined;
|
||||
tasksToResume = [];
|
||||
}
|
||||
} else {
|
||||
// All prior work was finished/cancelled; invalidate its late pause.
|
||||
transferQuitPendingRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
@@ -399,8 +433,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
// Report refusal before recovery, which may itself take a long time.
|
||||
// A resume promise can span the entire transfer, so do not hold the
|
||||
// close gate until it settles. A later close must be able to pause again.
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -582,6 +582,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.refreshModelList': 'Refresh model list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
|
||||
@@ -966,6 +966,51 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Settings > Shortcuts -> key bindings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Next Tab',
|
||||
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
|
||||
'settings.shortcuts.binding.close-tab': 'Close Tab',
|
||||
'settings.shortcuts.binding.close-session': 'Close Session Pane',
|
||||
'settings.shortcuts.binding.new-tab': 'New Local Tab',
|
||||
'settings.shortcuts.binding.copy': 'Copy from Terminal',
|
||||
'settings.shortcuts.binding.paste': 'Paste to Terminal',
|
||||
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
|
||||
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
|
||||
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
|
||||
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
|
||||
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
|
||||
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
|
||||
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
|
||||
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
|
||||
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
|
||||
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
|
||||
'settings.shortcuts.binding.new-workspace': 'New Workspace',
|
||||
'settings.shortcuts.binding.snippets': 'Open Snippets',
|
||||
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.binding.open-settings': 'Open Settings',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Rename File',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
|
||||
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
'action.newSubfolder': 'New Subfolder',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
|
||||
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
'terminal.toolbar.sessionLog.open': 'Open',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
@@ -68,6 +77,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.sendToAll': 'Send to all sessions',
|
||||
'terminal.composeBar.sendToActive': 'Send to the active session only',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
@@ -868,6 +879,7 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Open target folder',
|
||||
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
|
||||
'sftp.transfers.copyTargetPath': 'Copy target path',
|
||||
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
|
||||
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
|
||||
'sftp.transfers.pathConflict.title': 'Transfer blocked',
|
||||
@@ -974,6 +975,9 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
||||
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
|
||||
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
'ai.providers.headers.value': 'Valor de la cabecera',
|
||||
'ai.providers.headers.add': 'Agregar cabecera',
|
||||
'ai.providers.headers.remove': 'Quitar cabecera',
|
||||
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
|
||||
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
|
||||
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
|
||||
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -565,6 +582,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
@@ -693,6 +711,9 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
|
||||
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
|
||||
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
'credentials.protectionUnavailable.action': 'Abrir configuración',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty está bloqueado',
|
||||
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
|
||||
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
|
||||
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
|
||||
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
|
||||
'appLock.passwordLabel': 'Contraseña de bloqueo',
|
||||
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
|
||||
'appLock.unlock': 'Desbloquear',
|
||||
'appLock.unlocking': 'Desbloqueando...',
|
||||
'appLock.usePassword': 'Usar contraseña',
|
||||
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
|
||||
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
|
||||
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparando {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
|
||||
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
|
||||
'appLock.reset.cancel': 'Cancelar',
|
||||
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.resetting': 'Restableciendo...',
|
||||
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
|
||||
'topTabs.lockApp': 'Bloquear Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Configuración',
|
||||
'settings.tab.application': 'Aplicación',
|
||||
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
|
||||
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Bloqueo de la aplicación',
|
||||
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
|
||||
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
|
||||
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
|
||||
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
|
||||
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
|
||||
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disabling': 'Desactivando...',
|
||||
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
|
||||
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
|
||||
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
|
||||
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
|
||||
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
|
||||
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
|
||||
'settings.appLock.timeout': 'Tiempo de inactividad',
|
||||
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
|
||||
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
|
||||
'settings.appLock.timeout.1': '1 minuto',
|
||||
'settings.appLock.timeout.5': '5 minutos',
|
||||
'settings.appLock.timeout.15': '15 minutos',
|
||||
'settings.appLock.timeout.30': '30 minutos',
|
||||
'settings.appLock.timeout.60': '60 minutos',
|
||||
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
|
||||
'settings.appLock.currentPassword': 'Contraseña actual',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
|
||||
'settings.appLock.newPassword': 'Nueva contraseña',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
|
||||
'settings.appLock.confirmPassword': 'Confirmar contraseña',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
|
||||
'settings.appLock.savePassword': 'Definir y activar',
|
||||
'settings.appLock.savingPassword': 'Guardando...',
|
||||
'settings.appLock.passwordSet': 'Contraseña configurada',
|
||||
'settings.appLock.replacePassword': 'Reemplazar contraseña',
|
||||
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
|
||||
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
|
||||
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
|
||||
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
|
||||
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
|
||||
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
|
||||
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Proxy de red',
|
||||
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
|
||||
@@ -913,6 +998,7 @@ Resalta el panel dividido enfocado:
|
||||
'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.toggle-host-tree': 'Alternar lista de hosts',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
|
||||
|
||||
"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",
|
||||
|
||||
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
|
||||
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
|
||||
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
'systemManager.k8s.status.Running': 'En ejecución',
|
||||
'systemManager.k8s.status.Pending': 'Pendiente',
|
||||
'systemManager.k8s.status.Completed': 'Completado',
|
||||
'systemManager.k8s.status.Succeeded': 'Con éxito',
|
||||
'systemManager.k8s.status.Failed': 'Fallido',
|
||||
'systemManager.k8s.status.Unknown': 'Desconocido',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
|
||||
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
|
||||
'systemManager.k8s.status.Terminating': 'Terminando',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
|
||||
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Desalojado',
|
||||
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
'terminal.toolbar.sessionLog.open': 'Abrir',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
|
||||
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
@@ -68,6 +77,8 @@ 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.sendToAll': 'Enviar a todas las sesiones',
|
||||
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
|
||||
'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',
|
||||
@@ -208,6 +219,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.connected': 'Conectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
@@ -664,6 +676,45 @@ export const esTerminalMessages: Messages = {
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
'keychain.generate.standardKeys': 'Claves estándar',
|
||||
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
|
||||
'keychain.generate.generating': 'Generando…',
|
||||
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
|
||||
'keychain.generate.resident': 'Guardar las credenciales en la llave',
|
||||
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
|
||||
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
|
||||
|
||||
// FIDO2 security key prompts and recovery states
|
||||
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
|
||||
'fido.prompt.details': 'Detalles de la solicitud',
|
||||
'fido.prompt.showPin': 'Mostrar PIN',
|
||||
'fido.prompt.hidePin': 'Ocultar PIN',
|
||||
'fido.prompt.verifying': 'Verificando…',
|
||||
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
|
||||
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
|
||||
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
|
||||
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Esperando tu llave…',
|
||||
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
|
||||
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
|
||||
'fido.prompt.touchDone': 'Ya la toqué',
|
||||
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
|
||||
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
|
||||
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
|
||||
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
|
||||
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
|
||||
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
|
||||
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
|
||||
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
|
||||
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
|
||||
'fido.recovery.retry': 'Reintentar verificación',
|
||||
'fido.recovery.usePassword': 'Usar la contraseña configurada',
|
||||
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
|
||||
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
@@ -830,6 +881,7 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
|
||||
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
|
||||
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
|
||||
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
|
||||
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
|
||||
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
|
||||
@@ -964,6 +965,9 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
|
||||
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
|
||||
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
|
||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||
@@ -1043,6 +1047,18 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Probar conexión',
|
||||
'hostConnectionTest.test': 'Probar',
|
||||
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
|
||||
'hostConnectionTest.success': 'Conexión correcta',
|
||||
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
|
||||
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
|
||||
'hostConnectionTest.failed': 'No se pudo conectar.',
|
||||
'hostConnectionTest.retry': 'Reintentar',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
|
||||
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import es from "./es.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
// Locales that are not at full key parity with `en` yet. They are tracked here
|
||||
// so the remaining debt stays visible instead of silently rendering in English
|
||||
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
|
||||
// Move a locale out of this list once it covers every `en` key.
|
||||
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
|
||||
|
||||
const hasKey = (messages: Record<string, string>, key: string) =>
|
||||
Object.hasOwn(messages, key);
|
||||
|
||||
const interpolationTokens = (value: string) =>
|
||||
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
|
||||
.map((match) => match[0].replace(/[{}]/g, "").trim())
|
||||
.sort();
|
||||
|
||||
test("es defines every message key that en defines", () => {
|
||||
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`es is missing ${missing.length} message keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es defines no message keys that en does not define", () => {
|
||||
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
|
||||
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
`es defines ${extra.length} keys that no longer exist in en`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es keeps the same interpolation placeholders as en", () => {
|
||||
const mismatched: string[] = [];
|
||||
|
||||
for (const key of Object.keys(es)) {
|
||||
if (!hasKey(en, key)) continue;
|
||||
|
||||
const expected = interpolationTokens(en[key] ?? "").join(",");
|
||||
const actual = interpolationTokens(es[key] ?? "").join(",");
|
||||
if (expected !== actual) mismatched.push(key);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
mismatched,
|
||||
[],
|
||||
`es changes interpolation placeholders for ${mismatched.length} keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every default shortcut has a label in en and es", () => {
|
||||
const locales = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = DEFAULT_KEY_BINDINGS.map(
|
||||
(binding) => `settings.shortcuts.binding.${binding.id}`,
|
||||
).filter((key) => !hasKey(locale.messages, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} shortcut labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
|
||||
// SettingsShortcutsTab falls back to `binding.label` only while the locale
|
||||
// resolves the key to itself. Once `en` defines the key, the locale becomes
|
||||
// the rendered source, so the two must not drift apart.
|
||||
for (const binding of DEFAULT_KEY_BINDINGS) {
|
||||
const key = `settings.shortcuts.binding.${binding.id}`;
|
||||
|
||||
assert.equal(
|
||||
en[key],
|
||||
binding.label,
|
||||
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every locale outside the pending list is at full parity with en", () => {
|
||||
const locales = [
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(en).filter(
|
||||
(key) => !hasKey(locale.messages, key),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} message keys`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -521,6 +521,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.refreshModelList': 'Обновить список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
|
||||
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
|
||||
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
'terminal.toolbar.sessionLog.open': 'Открыть',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
@@ -89,6 +98,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
|
||||
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
@@ -875,6 +886,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
|
||||
@@ -413,6 +413,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
|
||||
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
|
||||
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
|
||||
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
|
||||
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
|
||||
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
|
||||
@@ -1013,6 +1014,9 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
|
||||
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
const ALL_SETTINGS_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import en from "./en";
|
||||
import es from "./es";
|
||||
import ru from "./ru";
|
||||
import zhCN from "./zh-CN";
|
||||
import zhTW from "./zh-TW";
|
||||
|
||||
test("folder cleanup-warning counts are localized in every supported locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
|
||||
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en';
|
||||
import es from './es';
|
||||
import ru from './ru';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const keys = [
|
||||
'terminal.toolbar.openSessionLog',
|
||||
'terminal.toolbar.sessionLogActions',
|
||||
'terminal.toolbar.sessionLog.menu',
|
||||
'terminal.toolbar.sessionLog.open',
|
||||
'terminal.toolbar.sessionLog.reveal',
|
||||
'terminal.toolbar.sessionLog.chooseApplication',
|
||||
'terminal.toolbar.sessionLog.systemDefault',
|
||||
'terminal.toolbar.openSessionLogFailed',
|
||||
] as const;
|
||||
|
||||
test('saved session log actions are translated in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
|
||||
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
|
||||
}
|
||||
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
|
||||
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
|
||||
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
|
||||
});
|
||||
@@ -582,6 +582,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.refreshModelList': '刷新模型列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
|
||||
@@ -1035,6 +1035,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'sftp.transfers.openTargetFolder': '打开目标目录',
|
||||
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
|
||||
'sftp.transfers.copyTargetPath': '复制目标路径',
|
||||
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
|
||||
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
|
||||
'sftp.transfers.pathConflict.title': '无法开始传输',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
'terminal.toolbar.sessionLog.open': '打开',
|
||||
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
|
||||
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
|
||||
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
@@ -612,6 +621,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
@@ -959,6 +969,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
|
||||
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
@@ -524,6 +527,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.sendToAll': '发送到所有会话',
|
||||
'terminal.composeBar.sendToActive': '仅发送到当前会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
|
||||
@@ -572,6 +572,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.refreshModelList': '重新整理模型清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
|
||||
@@ -1037,6 +1037,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'sftp.transfers.openTargetFolder': '開啟目標目錄',
|
||||
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
|
||||
'sftp.transfers.copyTargetPath': '複製目標路徑',
|
||||
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
|
||||
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
|
||||
'sftp.transfers.pathConflict.title': '無法開始傳輸',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
'terminal.toolbar.sessionLog.open': '開啟',
|
||||
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
|
||||
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
|
||||
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
@@ -612,6 +621,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
|
||||
@@ -959,6 +969,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流量控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬體',
|
||||
'serial.type.pseudo': '虛擬終端',
|
||||
|
||||
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
@@ -524,6 +527,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
|
||||
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
|
||||
@@ -717,3 +717,103 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(freezeHistory);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
count - index,
|
||||
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
|
||||
id: `message-${index}-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'ordinary text '.repeat(8),
|
||||
timestamp: messageIndex,
|
||||
...(messageIndex % 2 ? {
|
||||
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: 'reasoning',
|
||||
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
|
||||
}],
|
||||
},
|
||||
} : {}),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
|
||||
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
|
||||
const expected = sessions.slice(0, 50);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
|
||||
const result = serializeSessionsForStorage(sessions);
|
||||
|
||||
assert.equal(stringify.mock.callCount(), expected.length);
|
||||
assert.equal(result.json, expectedJson);
|
||||
assert.deepEqual(result.sessions, expected);
|
||||
assert.equal(result.sessions[0], sessions[0]);
|
||||
});
|
||||
}
|
||||
|
||||
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = makePersistenceHistory(1);
|
||||
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
|
||||
freezeHistory(sessions);
|
||||
const fullJson = JSON.stringify(sessions);
|
||||
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
|
||||
for (const budget of [fullJson.length, fullJson.length + 1]) {
|
||||
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
|
||||
}
|
||||
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
|
||||
assert.ok(overflow.json.length < fullJson.length);
|
||||
assert.equal(JSON.stringify(sessions), fullJson);
|
||||
});
|
||||
|
||||
test('fit-first empty history preserves output even below the minimum array budget', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
for (const budget of [0, 1, 2, 3]) {
|
||||
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(20));
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
const result = serializeSessionsForStorage(sessions, budget);
|
||||
// Same work as the original ladder: no second full-session serialization
|
||||
// after the fit check, even when a last-resort degraded chat is needed.
|
||||
assert.equal(stringify.mock.callCount(), expectedCalls);
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
@@ -363,12 +363,30 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
@@ -379,7 +397,6 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
@@ -145,3 +145,16 @@ test("a closing destination rejects a popout without releasing source content",
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
|
||||
|
||||
test("detached editor dirty-check replies echo the main-process request", (t) => {
|
||||
let query!: (request: { requestId: string; deadline: number }) => void;
|
||||
const replies: unknown[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
|
||||
}));
|
||||
t.after(subscribeEditorWindowDirtyCheck(() => true));
|
||||
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
|
||||
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
|
||||
});
|
||||
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
return bridge.onCheckDirtyEditors((request) => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
|
||||
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "plain"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
return sessionId === "plain";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["images", "plain"]);
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
|
||||
const blocked: string[] = [];
|
||||
const blockedOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "also-images"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => blockedOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
blocked.push(sessionId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(blocked, ["images", "also-images"]);
|
||||
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["victim"]);
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
|
||||
const recovered: string[] = [];
|
||||
const recoveredOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim", "next"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => recoveredOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
recovered.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return sessionId === "next";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered, ["victim", "next"]);
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
|
||||
let keepCount = 0;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 2;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 0;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
try {
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 1;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
|
||||
} finally {
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => false,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => true,
|
||||
});
|
||||
assert.deepEqual(outcomes, []);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
});
|
||||
|
||||
const outcomes: string[] = [];
|
||||
const pending = retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("leaving");
|
||||
outcomes.push("soft-hide");
|
||||
},
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId: "second",
|
||||
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
await pending;
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
|
||||
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
});
|
||||
});
|
||||
|
||||
const retained: string[] = [];
|
||||
const hibernated: string[] = [];
|
||||
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
||||
retained.push(sessionId);
|
||||
},
|
||||
fullHibernate: () => hibernated.push(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(hibernated.length, 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
/**
|
||||
* Walk keep-count victims until one hibernate completes, then retain the
|
||||
* leaving tab only if its reserved slot still fits.
|
||||
*
|
||||
* The reservation is taken before the first await. Clearing a victim marker
|
||||
* during wake would otherwise look like spare capacity to a second hidden-tab
|
||||
* timer, and both tabs would soft-hide once the same eviction resolved.
|
||||
*/
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
|
||||
keepCount: number;
|
||||
/**
|
||||
* Live keep-count read after eviction settles. Settings can change while the
|
||||
* victim drains or serializes; retaining against the captured count would
|
||||
* soft-hide a tab the user no longer wants kept.
|
||||
*/
|
||||
resolveKeepCount?: () => number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
evict?: (sessionId: string) => Promise<boolean> | boolean;
|
||||
}): Promise<void> {
|
||||
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
|
||||
try {
|
||||
const evict = input.evict ?? ((sessionId: string) => (
|
||||
terminalHiddenRendererStore.evictSoftHidden(sessionId)
|
||||
));
|
||||
let completed = false;
|
||||
for (const candidate of input.candidates) {
|
||||
if (!candidate || candidate === input.sessionId) continue;
|
||||
if (await evict(candidate)) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
}
|
||||
input.fullHibernate();
|
||||
} finally {
|
||||
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { performSessionLogAction } from './sessionLogActions';
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
const calls: unknown[][] = [];
|
||||
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
|
||||
let available = true;
|
||||
let failOpen = false;
|
||||
const bridge = {
|
||||
prepareSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['prepare', payload]);
|
||||
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
|
||||
: { success: false, error: 'No saved log for this session' };
|
||||
},
|
||||
revealSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['reveal', payload]);
|
||||
return { success: true };
|
||||
},
|
||||
selectApplication: async () => { calls.push(['choose']); return picked; },
|
||||
openWithApplication: async (file: string, app: string) => {
|
||||
calls.push(['application', file, app]);
|
||||
if (failOpen) throw new Error('Launch failed');
|
||||
return true;
|
||||
},
|
||||
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
|
||||
};
|
||||
Object.assign(dom.window, { netcatty: bridge });
|
||||
try {
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
|
||||
await performSessionLogAction('second', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [
|
||||
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
|
||||
['application', '/logs/second.html', picked!.path],
|
||||
]);
|
||||
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
|
||||
await performSessionLogAction('first', 'reveal');
|
||||
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
|
||||
await performSessionLogAction('first', 'systemDefault');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
calls.length = 0;
|
||||
picked = null;
|
||||
await performSessionLogAction('first', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
|
||||
available = false;
|
||||
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
|
||||
available = true;
|
||||
picked = { path: '/missing-editor', name: 'Missing' };
|
||||
failOpen = true;
|
||||
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The integration boundary must translate backend errors, including ordinary
|
||||
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
|
||||
test('terminal log action errors use the translated toast instead of raw backend text', () => {
|
||||
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
|
||||
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
|
||||
source.indexOf(' const handleToggleSessionLog ='));
|
||||
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
|
||||
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
|
||||
assert.doesNotMatch(handler, /err\.message/);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
|
||||
type LogApplication = { path: string; name: string };
|
||||
|
||||
/** Resolve the session's actual log in its owning process before opening it. */
|
||||
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
|
||||
if (action === 'reveal') {
|
||||
const result = await bridge.revealSessionLog?.({ sessionId });
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability before presenting an application picker. Resolve again
|
||||
// afterwards so any output received while the picker was open is flushed.
|
||||
let result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
if (action === 'chooseApplication') {
|
||||
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
|
||||
application = await bridge.selectApplication();
|
||||
if (!application) return;
|
||||
result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
} else if (action === 'systemDefault') {
|
||||
application = null;
|
||||
}
|
||||
|
||||
if (application?.path) {
|
||||
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
|
||||
const opened = await bridge.openWithApplication(result.filePath, application.path);
|
||||
if (!opened) throw new Error('Failed to open session log');
|
||||
} else {
|
||||
const opened = await bridge.openWithSystemDefault?.(result.filePath);
|
||||
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
|
||||
}
|
||||
// Only remember a choice after a successful launch; failed launches leave
|
||||
// the previous preference intact and can be retried or reset from the menu.
|
||||
if (action === 'chooseApplication') {
|
||||
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
|
||||
} else if (action === 'systemDefault') {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ test('session actions register and unregister', () => {
|
||||
createWorkspaceFromSessions: noop,
|
||||
addSessionToWorkspace: noop,
|
||||
appendHostToWorkspace: noop,
|
||||
appendHostToWorkspaceConfirmed: noop,
|
||||
appendLocalTerminalToWorkspace: noop,
|
||||
createWorkspaceFromTargets: noop,
|
||||
updateSplitSizes: noop,
|
||||
@@ -102,6 +103,10 @@ test('session actions register and unregister', () => {
|
||||
reorderTabs: noop,
|
||||
toggleBroadcast: noop,
|
||||
isBroadcastEnabled: noop,
|
||||
toggleComposeSendTarget: noop,
|
||||
isComposeSendToAll: noop,
|
||||
toggleGlobalComposeSendTarget: noop,
|
||||
isGlobalComposeSendToAll: false,
|
||||
copySession: noop,
|
||||
copyWorkspace: noop,
|
||||
createSessionFromCloneSource: noop,
|
||||
|
||||
@@ -57,6 +57,7 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
| 'createWorkspaceFromTargets'
|
||||
| 'updateSplitSizes'
|
||||
@@ -68,8 +69,12 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleComposeSendTarget'
|
||||
| 'isComposeSendToAll'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'toggleGlobalComposeSendTarget'
|
||||
| 'isGlobalComposeSendToAll'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
|
||||
@@ -10,6 +10,18 @@ const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
|
||||
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
|
||||
for (const error of [
|
||||
"read ECONNABORTED",
|
||||
"socket hang up",
|
||||
"ERR_SFTP_CHANNEL_CLOSED",
|
||||
"SFTP pipelined upload failed: socket hang up",
|
||||
"client-socket",
|
||||
]) {
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
|
||||
}
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
|
||||
assert.equal(canRetryFromCheckpoint(failed()), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { TransferTask } from "../../../domain/models";
|
||||
// .part is intact up to the checkpoint, so Retry can continue from it instead
|
||||
// of restarting at byte 0 (which also strands the old stage on the server,
|
||||
// because the new attempt stages under a fresh transfer id).
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
|
||||
// cover every code or message isSftpChannelLossError already treats as channel
|
||||
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
|
||||
// client-socket). A miss here restarts at byte 0 and strands the .part.
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
|
||||
|
||||
/**
|
||||
* True when Retry should resume the same task from its checkpoint.
|
||||
|
||||
@@ -163,6 +163,9 @@ export async function restoreSftpTransferHistoryCooperatively(
|
||||
const child = parentChildren[childIndex];
|
||||
if (
|
||||
child.status === "completed"
|
||||
// Match canonical pruning: warning rows carry recovery paths that
|
||||
// cannot be represented by the directory's compact manifest.
|
||||
&& !child.warning
|
||||
&& Number.isSafeInteger(child.directoryEntryIndex)
|
||||
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
|
||||
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
|
||||
|
||||
@@ -135,6 +135,34 @@ test("completed dispatch consumes only exact identity and never restarts", async
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
|
||||
store.upsertTasks([root, task]);
|
||||
t.after(() => store.dismiss(root.id));
|
||||
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
|
||||
assert.equal(result?.warning, warning);
|
||||
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
|
||||
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
|
||||
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
|
||||
assert.equal(store.getTask(task.id)?.warning, warning);
|
||||
});
|
||||
|
||||
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
|
||||
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface FileWatchSyncedEvent {
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
bytesWritten: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface FileWatchErrorEvent {
|
||||
|
||||
@@ -156,6 +156,10 @@ export const createUploadTaskCallbacks = ({
|
||||
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
|
||||
});
|
||||
},
|
||||
onTaskWarning: (taskId: string, warning: string) => {
|
||||
// Preserve cleanup details before completion can compact a child row.
|
||||
store.patchTask(taskId, { warning });
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
store.patchTask(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
|
||||
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
|
||||
|
||||
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
@@ -37,7 +37,17 @@ export async function runTransferAndWaitForOwner(
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
|
||||
const result = await start();
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
if (!result?.superseded) return result;
|
||||
if (!result?.superseded) {
|
||||
// The invoke response may arrive before completed IPC. Preserve cleanup
|
||||
// details before the caller completes (and may compact) this child.
|
||||
if (result?.warning && !result.error && !result.cancelled) {
|
||||
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
|
||||
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
|
||||
@@ -33,6 +33,85 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("completion cleanup warnings survive owner snapshots and restored history", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = makeTask("cleanup-warning");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
assert.equal(restored.getTask(source.id)?.status, "completed");
|
||||
assert.equal(restored.getTask(source.id)?.error, undefined);
|
||||
});
|
||||
|
||||
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-snapshot");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
|
||||
});
|
||||
|
||||
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-event");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
});
|
||||
|
||||
test("late completion enriches a locally completed task with its cleanup warning", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const endTime = Date.now();
|
||||
store.publishOwner("panel", [source]);
|
||||
// The invoke response can settle the renderer before the completed IPC arrives.
|
||||
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribe(() => { notifications += 1; });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
|
||||
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
for (const status of ["failed", "cancelled"] as const) {
|
||||
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const endTime = Date.now();
|
||||
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
|
||||
store.publishOwner("panel", [task]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
assert.equal(store.getTask(task.id)?.error, "original outcome");
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
|
||||
const calls: string[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
@@ -100,6 +179,10 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
|
||||
for (const [label, error, expectResume] of [
|
||||
["dead channel", "SFTP channel closed", true],
|
||||
["lost session", "SFTP session lost. Please reconnect.", true],
|
||||
["aborted socket", "read ECONNABORTED", true],
|
||||
["hung up socket", "socket hang up", true],
|
||||
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
|
||||
["client socket", "client-socket", true],
|
||||
["content failure", "Permission denied", false],
|
||||
] as const) {
|
||||
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
|
||||
@@ -3572,6 +3655,46 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
|
||||
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
|
||||
});
|
||||
|
||||
for (const indexed of [false, true]) {
|
||||
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
|
||||
const now = Date.now();
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
|
||||
const children: TransferTask[] = [0, 1].map((index) => ({
|
||||
...makeTask(`restore-warning-child-${index}`, "completed"),
|
||||
parentTaskId: parent.id, endTime: now,
|
||||
warning: index === 0 ? warning : undefined,
|
||||
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
|
||||
}));
|
||||
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let unsubscribe = () => {};
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("timed out waiting for cleanup-warning history migration"));
|
||||
}, 10_000);
|
||||
const check = () => {
|
||||
if (!store.getTask(parent.id)) return;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
unsubscribe = store.subscribe(check);
|
||||
check();
|
||||
});
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
|
||||
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
|
||||
const legacyOnly = makeTask("legacy-only", "paused");
|
||||
const staleCollision = makeTask("collision", "paused");
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface SftpTransferCenterStore {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
transferred?: number;
|
||||
totalBytes?: number;
|
||||
speed?: number;
|
||||
@@ -289,6 +290,12 @@ export function mergeOwnerPublishedTask(
|
||||
updatedAt: incoming.updatedAt ?? Date.now(),
|
||||
};
|
||||
|
||||
// A panel snapshot may predate cleanup metadata returned by the invoke or
|
||||
// completed IPC. Keep it through this attempt, but not a restart or failure.
|
||||
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
|
||||
merged.warning = incoming.warning ?? existing.warning;
|
||||
}
|
||||
|
||||
if (incomingReset || incomingTerminal) {
|
||||
return merged;
|
||||
}
|
||||
@@ -2274,6 +2281,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (terminal && event.type !== "cancelled") {
|
||||
// Allow a late explicit cancel to stick; ignore everything else.
|
||||
if (event.type === "completed" || event.type === "failed") {
|
||||
// An invoke response can complete the renderer row before its IPC
|
||||
// event arrives. Enrich that same success with cleanup metadata,
|
||||
// without replacing any terminal outcome or reviving stopped work.
|
||||
if (existing.status === "completed" && event.type === "completed"
|
||||
&& event.warning && event.warning !== existing.warning) {
|
||||
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
|
||||
emit(true);
|
||||
return;
|
||||
}
|
||||
// Keep existing terminal state (prefer cancelled over late completed).
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
@@ -2561,6 +2577,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
? task.checkpointBytes
|
||||
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
|
||||
error: event.error,
|
||||
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
|
||||
endTime: event.endedAt ?? Date.now(),
|
||||
speed: 0,
|
||||
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
|
||||
|
||||
@@ -9,6 +9,106 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
|
||||
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
|
||||
const sessions = ["images", "plain", "other"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
terminalHiddenRendererStore.markSoftHidden("plain");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
|
||||
terminalHiddenRendererStore.clearSoftHidden("plain");
|
||||
terminalHiddenRendererStore.settleEviction("plain", true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
const delivered: string[] = [];
|
||||
const later = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
|
||||
});
|
||||
try {
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
assert.deepEqual(delivered, []);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
} finally {
|
||||
later();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reserved leaving slot counts toward occupancy until it is released", () => {
|
||||
const sessions = ["a", "b", "c", "d"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
|
||||
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
|
||||
|
||||
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
|
||||
|
||||
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
|
||||
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
let markerClearedBeforeSettle = false;
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
|
||||
assert.equal(markerClearedBeforeSettle, true);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
}
|
||||
});
|
||||
@@ -3,14 +3,85 @@ type Listener = () => void;
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
|
||||
const reservedLeavingSlots = new Map<string, number>();
|
||||
let reservationToken = 0;
|
||||
|
||||
function reservedLeavingCount(): number {
|
||||
let extra = 0;
|
||||
for (const sessionId of reservedLeavingSlots.keys()) {
|
||||
if (!softHiddenSessions.has(sessionId)) extra += 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
type EvictionWaiter = {
|
||||
promise: Promise<boolean>;
|
||||
resolve: (completed: boolean) => void;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
const evictionWaiters = new Map<string, EvictionWaiter>();
|
||||
|
||||
function finishEviction(sessionId: string, completed: boolean): void {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
if (!waiter || waiter.settled) return;
|
||||
waiter.settled = true;
|
||||
evictionWaiters.delete(sessionId);
|
||||
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
|
||||
waiter.resolve(completed);
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function listSoftHiddenOldestFirst(): string[] {
|
||||
return [...softHiddenSessions.entries()]
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.map(([sessionId]) => sessionId);
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
/**
|
||||
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
|
||||
* yet in the marker set. Keep-count decisions use this so a second timer
|
||||
* cannot take the slot a pending eviction already claimed.
|
||||
*/
|
||||
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
|
||||
|
||||
/**
|
||||
* Claim one keep slot for a leaving tab before awaiting eviction.
|
||||
* A session already in the marker set is not counted twice. A later claim
|
||||
* for the same id replaces the token; only that token can release it.
|
||||
*/
|
||||
reserveLeavingSlot: (sessionId: string): number => {
|
||||
const token = ++reservationToken;
|
||||
if (sessionId && !softHiddenSessions.has(sessionId)) {
|
||||
reservedLeavingSlots.set(sessionId, token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
releaseLeavingSlot: (sessionId: string, token: number) => {
|
||||
if (reservedLeavingSlots.get(sessionId) === token) {
|
||||
reservedLeavingSlots.delete(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* True when retaining this leaving tab would stay within keepCount.
|
||||
* Its own reservation is not counted against the slots held by others.
|
||||
*/
|
||||
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
|
||||
if (!(keepCount > 0)) return false;
|
||||
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
|
||||
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
|
||||
return others < keepCount;
|
||||
},
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
@@ -23,18 +94,12 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
return listSoftHiddenOldestFirst()[0] ?? null;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
@@ -42,6 +107,44 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
/** True while this session's hibernate has been requested and not settled. */
|
||||
hasPendingEviction: (sessionId: string): boolean => {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
return waiter != null && !waiter.settled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask one soft-hidden session to upgrade. Resolves true only when
|
||||
* settleEviction reports that hibernation finished. Clearing the marker
|
||||
* during wake is not success: drain, snapshot, or alternate-screen failure
|
||||
* can still restore that renderer. A synchronous refusal (marker still
|
||||
* present, including inline images) resolves false and drops an unconsumed
|
||||
* request so a later emit cannot upgrade it.
|
||||
*/
|
||||
evictSoftHidden: (sessionId: string): Promise<boolean> => {
|
||||
const existing = evictionWaiters.get(sessionId);
|
||||
if (existing) return existing.promise;
|
||||
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise!: (completed: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
|
||||
evictionWaiters.set(sessionId, waiter);
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
|
||||
finishEviction(sessionId, false);
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** Record whether the in-flight eviction's hibernate actually completed. */
|
||||
settleEviction: (sessionId: string, completed: boolean) => {
|
||||
finishEviction(sessionId, completed);
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
|
||||
@@ -125,6 +125,55 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
|
||||
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
|
||||
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 900, 0, 400, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
|
||||
const editor = panel(f, 450, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
|
||||
f.resize(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
|
||||
editor.parentElement!.remove(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 0, 0, 300, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
const select = f.overlay(tooltip, 140, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
|
||||
select.remove();
|
||||
f.overlay(tooltip, 320, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
|
||||
'pictures are placed where the narrowed page sits inside the pane');
|
||||
});
|
||||
|
||||
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const middle = panel(f, 300, 0, 100, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
middle.parentElement!.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
panel(f, 250, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -70,12 +71,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
// Overlay panels stay open while the user works, so the live page moves
|
||||
// beside them when it can and only falls back to a picture otherwise.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
||||
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
||||
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
||||
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
||||
// inside the host editor) no longer touch the live page.
|
||||
const covered = modal || (!!rect && !live)
|
||||
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -90,7 +100,11 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const pane = live || rect;
|
||||
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
||||
// Pictures are relative to the native page, which may sit beside a panel.
|
||||
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
||||
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -102,7 +116,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
||||
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
||||
})) } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -119,14 +135,15 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface ServerStats {
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
|
||||
diskWriteSpeed?: number | null;
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
@@ -240,6 +242,8 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
diskPercent: stats.diskPercent ?? null,
|
||||
diskUsed: stats.diskUsed ?? null,
|
||||
diskTotal: stats.diskTotal ?? null,
|
||||
diskReadSpeed: stats.diskReadSpeed ?? null,
|
||||
diskWriteSpeed: stats.diskWriteSpeed ?? null,
|
||||
disks: stats.disks || [],
|
||||
netRxSpeed: stats.netRxSpeed || 0,
|
||||
netTxSpeed: stats.netTxSpeed || 0,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
|
||||
|
||||
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("compose send target follows toolbar broadcast and then flips independently", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
|
||||
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import {
|
||||
@@ -25,6 +26,10 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
} from '../../domain/terminalBroadcast';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
@@ -235,8 +240,12 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
|
||||
// then flip it without changing keystroke fan-out.
|
||||
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -907,6 +916,25 @@ export const useSessionState = ({
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Agent callers bind scope immediately, so they need committed insertion,
|
||||
// not just the ID allocated by the ordinary batched UI append operation.
|
||||
const appendHostToWorkspaceConfirmed = useCallback((
|
||||
workspaceId: string,
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
let sessionId: string | null = null;
|
||||
flushSync(() => {
|
||||
sessionId = appendHostToWorkspace(workspaceId, host, direction);
|
||||
});
|
||||
if (!sessionId) return null;
|
||||
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
|
||||
const inserted = committedWorkspace
|
||||
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
|
||||
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
|
||||
return inserted ? sessionId : null;
|
||||
}, [appendHostToWorkspace]);
|
||||
|
||||
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
||||
// but constructs a local-protocol session instead of an SSH one.
|
||||
const appendLocalTerminalToWorkspace = useCallback((
|
||||
@@ -1137,8 +1165,10 @@ export const useSessionState = ({
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
// Compose-bar send target follows the toolbar switch back to active-only.
|
||||
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
|
||||
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
|
||||
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
@@ -1273,31 +1303,61 @@ export const useSessionState = ({
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
|
||||
setComposeSendAllWorkspaceIds((prev) => {
|
||||
const hasAll = prev.has(workspaceId);
|
||||
if ((target === 'all') === hasAll) return prev;
|
||||
const next = new Set(prev);
|
||||
if (next.has(workspaceId)) {
|
||||
next.delete(workspaceId);
|
||||
} else {
|
||||
next.add(workspaceId);
|
||||
}
|
||||
if (target === 'all') next.add(workspaceId);
|
||||
else next.delete(workspaceId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
const enabling = !broadcastWorkspaceIds.has(workspaceId);
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (prev.has(workspaceId)) next.delete(workspaceId);
|
||||
else next.add(workspaceId);
|
||||
return next;
|
||||
});
|
||||
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
|
||||
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
// Check if a workspace has broadcast enabled
|
||||
const isBroadcastEnabled = useCallback((workspaceId: string) => {
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
|
||||
setWorkspaceComposeSendTarget(
|
||||
workspaceId,
|
||||
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
|
||||
);
|
||||
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
const isComposeSendToAll = useCallback((workspaceId: string) => {
|
||||
return composeSendAllWorkspaceIds.has(workspaceId);
|
||||
}, [composeSendAllWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
setGlobalBroadcastEnabled(prev => {
|
||||
const next = !prev;
|
||||
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleGlobalComposeSendTarget = useCallback(() => {
|
||||
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
const isGlobalComposeSendToAll = globalComposeSendToAll;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
@@ -1413,6 +1473,7 @@ export const useSessionState = ({
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
@@ -1426,8 +1487,12 @@ export const useSessionState = ({
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import { useSessionState } from './useSessionState';
|
||||
|
||||
test('workspace host opening acknowledges committed insertion and rejects queued closure', async (t) => {
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
|
||||
const globals = {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
localStorage: dom.window.localStorage,
|
||||
CustomEvent: dom.window.CustomEvent,
|
||||
Event: dom.window.Event,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
t.after(async () => {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
});
|
||||
let state: ReturnType<typeof useSessionState>;
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
await act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(Probe))));
|
||||
const host = createLocalShellHost({ id: 'local-inheriting' });
|
||||
await act(async () => state.createWorkspaceWithHosts('Target', [host]));
|
||||
const workspaceId = state!.workspaces[0].id;
|
||||
const resolvedHost = resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/tmp/project' },
|
||||
});
|
||||
let ordinaryId: string | null = null;
|
||||
await act(async () => {
|
||||
ordinaryId = state.appendHostToWorkspace(workspaceId, resolvedHost);
|
||||
});
|
||||
assert.ok(ordinaryId);
|
||||
assert.ok(state!.sessions.some(session => session.id === ordinaryId));
|
||||
assert.ok(collectSessionIds(state!.workspaces[0].root).includes(ordinaryId));
|
||||
let insertedId: string | null = null;
|
||||
await act(async () => {
|
||||
insertedId = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
|
||||
assert.ok(insertedId, 'success is returned after both state updates commit');
|
||||
const session = state.sessions.find(s => s.id === insertedId);
|
||||
assert.equal(session?.localShell, '/bin/zsh');
|
||||
assert.deepEqual(session?.localShellArgs, ['-l']);
|
||||
assert.equal(session?.localStartDir, '/tmp/project');
|
||||
assert.equal(state.sessions.filter(s => s.id === insertedId).length, 1);
|
||||
assert.equal(collectSessionIds(state.workspaces[0].root).filter(id => id === insertedId).length, 1);
|
||||
});
|
||||
await act(async () => {
|
||||
// The rendered ref still contains the workspace; its close updater is queued.
|
||||
state.closeWorkspace(workspaceId);
|
||||
const result = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
|
||||
assert.equal(result, null, 'must not acknowledge an allocated but uninserted ID');
|
||||
});
|
||||
assert.equal(state!.workspaces.length, 0);
|
||||
assert.equal(state!.sessions.length, 0, 'no orphan session survives the queued close');
|
||||
assert.equal(activeTabStore.getActiveTabId(), 'vault');
|
||||
await act(async () => {
|
||||
assert.equal(state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('agent workspace host opening uses the common launch and history handler', () => {
|
||||
const source = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /const sessionId = handleConnectToHost\(host, true, hidden, target\.workspaceId\)/);
|
||||
assert.match(source, /connectToHost: workspaceId\s*\? \(candidate: Host\) => appendHostToWorkspaceConfirmed\(workspaceId, candidate\)\s*: connectToHost/);
|
||||
});
|
||||
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
|
||||
contextViewportSnapshot: string;
|
||||
contextScrollbackSnapshot: string;
|
||||
alternateScreen: boolean;
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
},
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import React, { startTransition, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
|
||||
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
|
||||
|
||||
@@ -130,3 +135,231 @@ test('closing the final pane removes only that terminal side panel state', async
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let renders = 0;
|
||||
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
renders += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
renders = 0;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
assert.equal(renders, 1, 'open tabs and layout share one commit');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
|
||||
const layout = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.equal(
|
||||
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
|
||||
'notes',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'ai', 'vertical', {
|
||||
paneId: 'pane-ai',
|
||||
splitId: 'split-a',
|
||||
}, 400);
|
||||
});
|
||||
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
|
||||
['notes', 'ai'],
|
||||
);
|
||||
assert.equal(
|
||||
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
|
||||
'notes',
|
||||
);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
|
||||
assert.notEqual(focusedExisting, beforeFocus);
|
||||
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
|
||||
|
||||
const settled = state!.sidePanelLayouts;
|
||||
renders = 0;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
assert.equal(renders, 0);
|
||||
assert.equal(state!.sidePanelLayouts, settled);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending side-panel write survives an urgent render on another tab', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let bump: ((value: number) => void) | null = null;
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
const [, setTick] = useState(0);
|
||||
bump = setTick;
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const tools = (tabId: string, fromRef = false) => {
|
||||
const layout = fromRef
|
||||
? state?.sidePanelLayoutsRef.current.get(tabId)
|
||||
: state?.sidePanelLayouts.get(tabId);
|
||||
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'ai', 'vertical', {
|
||||
paneId: 'pane-ai',
|
||||
splitId: 'split-a',
|
||||
}, 400);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'history');
|
||||
});
|
||||
flushSync(() => {
|
||||
bump?.(1);
|
||||
});
|
||||
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
|
||||
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
assert.deepEqual(tools('terminal-b'), ['sftp']);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const focusedTool = (tabId: string, fromRef = false) => {
|
||||
const layout = fromRef
|
||||
? state?.sidePanelLayoutsRef.current.get(tabId)
|
||||
: state?.sidePanelLayouts.get(tabId);
|
||||
if (!layout) return null;
|
||||
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'history');
|
||||
});
|
||||
flushSync(() => {
|
||||
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
|
||||
assert.equal(
|
||||
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
|
||||
'browser',
|
||||
'B stays rendered as browser while its ref still reports the pre-queue tool',
|
||||
);
|
||||
assert.equal(focusedTool('terminal-b', true), 'browser');
|
||||
|
||||
// handleSwitchSidePanelTab returns before select when the ref already
|
||||
// matches the clicked tool. A stale scripts ref would ignore this click.
|
||||
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
assert.equal(focusedTool('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
|
||||
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
|
||||
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
|
||||
const body = layerSource.slice(start, end);
|
||||
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
|
||||
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
|
||||
});
|
||||
@@ -13,55 +13,215 @@ import {
|
||||
type SidePanelTool,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
|
||||
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
|
||||
if (previous.get(key) === value) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(key, value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
|
||||
if (!previous.has(key)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(key);
|
||||
return next;
|
||||
}
|
||||
|
||||
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
|
||||
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a ref aligned with committed state without dropping a write React has
|
||||
* not rendered yet. An urgent update can rebase onto a transition: the
|
||||
* transition updater stores an intermediate map in the ref, and the later
|
||||
* updater changes only state. Copy committed entries the pending map did not
|
||||
* change, and follow committed once it moves past that pending value.
|
||||
*/
|
||||
function syncTrackedMap<T>(
|
||||
pending: Map<string, T>,
|
||||
lastRendered: Map<string, T>,
|
||||
committed: Map<string, T>,
|
||||
): Map<string, T> {
|
||||
if (pending === lastRendered) return committed;
|
||||
if (pending === committed) return pending;
|
||||
|
||||
let changed = false;
|
||||
const merged = new Map(pending);
|
||||
|
||||
for (const key of committed.keys()) {
|
||||
if (entryMatches(pending, committed, key)) continue;
|
||||
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
|
||||
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
|
||||
if (pendingMatchesRendered || !committedMatchesRendered) {
|
||||
merged.set(key, committed.get(key) as T);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of pending.keys()) {
|
||||
if (committed.has(key)) continue;
|
||||
if (entryMatches(pending, lastRendered, key)) {
|
||||
merged.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? merged : pending;
|
||||
}
|
||||
|
||||
function publishQueuedMap<T>(
|
||||
live: Map<string, T>,
|
||||
capturedBase: Map<string, T>,
|
||||
previous: Map<string, T>,
|
||||
stateNext: Map<string, T>,
|
||||
tabId: string,
|
||||
value: T | null,
|
||||
): Map<string, T> {
|
||||
// The captured map is the synchronous write. Replaying it after an
|
||||
// intervening render drops entries that render already merged. When the ref
|
||||
// has moved ahead of this updater's base, reapply only this tab.
|
||||
if (live === capturedBase || live === previous) return stateNext;
|
||||
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
|
||||
}
|
||||
|
||||
function reconcileSidePanelLayouts(
|
||||
layouts: Map<string, SidePanelLayout>,
|
||||
openTabs: Map<string, SidePanelTool>,
|
||||
): Map<string, SidePanelLayout> | null {
|
||||
let changed = false;
|
||||
const next = new Map(layouts);
|
||||
|
||||
for (const tabId of layouts.keys()) {
|
||||
if (!openTabs.has(tabId)) {
|
||||
next.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tabId, tool] of openTabs) {
|
||||
const current = next.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated !== current) {
|
||||
next.set(tabId, updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
export function useTerminalSidePanelLayoutState() {
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
|
||||
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
sidePanelLayoutsRef.current = sidePanelLayouts;
|
||||
// Last maps this hook rendered. A transition can update the refs before React
|
||||
// commits them; an urgent render in between must not copy the stale state
|
||||
// back over that pending write. Once the queue commits, entries the pending
|
||||
// write did not touch still have to catch up, or the ref stays on the
|
||||
// transition updater's intermediate map.
|
||||
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const renderedLayoutsRef = useRef(sidePanelLayouts);
|
||||
sidePanelOpenTabsRef.current = syncTrackedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
renderedOpenTabsRef.current,
|
||||
sidePanelOpenTabs,
|
||||
);
|
||||
renderedOpenTabsRef.current = sidePanelOpenTabs;
|
||||
sidePanelLayoutsRef.current = syncTrackedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
renderedLayoutsRef.current,
|
||||
sidePanelLayouts,
|
||||
);
|
||||
renderedLayoutsRef.current = sidePanelLayouts;
|
||||
|
||||
// Legacy and external open paths write the focused-tool map. Reconcile that
|
||||
// public application boundary into the per-tab tree without disturbing the
|
||||
// other panes in an existing layout.
|
||||
// other panes in an existing layout. Skip the setState when the click path
|
||||
// already stored this selection: scheduling an identical update still
|
||||
// renders again after a paired openTabs write.
|
||||
useEffect(() => {
|
||||
const base = sidePanelLayoutsRef.current;
|
||||
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
|
||||
if (!reconciled) return;
|
||||
sidePanelLayoutsRef.current = reconciled;
|
||||
setSidePanelLayouts((previous) => {
|
||||
let changed = false;
|
||||
const next = new Map(previous);
|
||||
|
||||
for (const tabId of previous.keys()) {
|
||||
if (!sidePanelOpenTabs.has(tabId)) {
|
||||
next.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
const next = previous === base
|
||||
? reconciled
|
||||
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
|
||||
if (!next) {
|
||||
sidePanelLayoutsRef.current = previous;
|
||||
return previous;
|
||||
}
|
||||
|
||||
for (const [tabId, tool] of sidePanelOpenTabs) {
|
||||
const current = next.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated !== current) {
|
||||
next.set(tabId, updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) sidePanelLayoutsRef.current = next;
|
||||
return changed ? next : previous;
|
||||
sidePanelLayoutsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [sidePanelOpenTabs]);
|
||||
|
||||
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
|
||||
const focusedTool = getFocusedSidePanelPane(layout).tool;
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
|
||||
const commitMap = useCallback((
|
||||
tabId: string,
|
||||
layout: SidePanelLayout,
|
||||
openTab: SidePanelTool | null,
|
||||
) => {
|
||||
const layoutBase = sidePanelLayoutsRef.current;
|
||||
const openTabBase = sidePanelOpenTabsRef.current;
|
||||
const layouts = openTab === null
|
||||
? deleteMapEntry(layoutBase, tabId)
|
||||
: replaceMapEntry(layoutBase, tabId, layout);
|
||||
const openTabs = openTab === null
|
||||
? deleteMapEntry(openTabBase, tabId)
|
||||
: replaceMapEntry(openTabBase, tabId, openTab);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
setSidePanelLayouts((previous) => {
|
||||
const next = previous === layoutBase
|
||||
? layouts
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
|
||||
sidePanelLayoutsRef.current = publishQueuedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
layoutBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab === null ? null : layout,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
const next = previous === openTabBase
|
||||
? openTabs
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
|
||||
sidePanelOpenTabsRef.current = publishQueuedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
openTabBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
|
||||
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
|
||||
}, [commitMap]);
|
||||
|
||||
// Click path writes the focused tool and the layout together. The effect
|
||||
// above still reconciles external openTabs writes, and skips when
|
||||
// selectSidePanelTool returns the layout this call just stored.
|
||||
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
|
||||
const current = sidePanelLayoutsRef.current.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
|
||||
commitLayout(tabId, updated);
|
||||
}, [commitLayout]);
|
||||
|
||||
const focusPane = useCallback((tabId: string, paneId: string) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
@@ -97,25 +257,23 @@ export function useTerminalSidePanelLayoutState() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const layouts = new Map(sidePanelLayoutsRef.current);
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current);
|
||||
layouts.delete(tabId);
|
||||
openTabs.delete(tabId);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
commitMap(tabId, layout, null);
|
||||
return true;
|
||||
}, [commitLayout]);
|
||||
}, [commitLayout, commitMap]);
|
||||
|
||||
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
|
||||
if (nextLayout === layout) return;
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
|
||||
const base = sidePanelLayoutsRef.current;
|
||||
const layouts = replaceMapEntry(base, tabId, nextLayout);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelLayouts((previous) => {
|
||||
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
|
||||
sidePanelLayoutsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -129,5 +287,6 @@ export function useTerminalSidePanelLayoutState() {
|
||||
splitPane,
|
||||
closePane,
|
||||
resizeSplit,
|
||||
selectSidePanelToolForTab,
|
||||
};
|
||||
}
|
||||
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
stopTunnel: current.stopTunnel,
|
||||
stopRuleTunnels: current.stopRuleTunnels,
|
||||
openHost: current.openHost
|
||||
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
|
||||
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
|
||||
: undefined,
|
||||
findExistingSessionForHost: current.findExistingSessionForHost,
|
||||
closeSession: current.closeSession
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
const terminalLayerSource = readFileSync(
|
||||
new URL("../../components/TerminalLayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
|
||||
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
|
||||
assert.match(source, /await writeTerminalDataMapDiff\(/);
|
||||
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
|
||||
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
|
||||
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("close-path connection log updates await the indexedDB persist task", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
|
||||
assert.match(source, /if \(!map\) return;/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
|
||||
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
|
||||
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
|
||||
assert.notEqual(revAt, -1);
|
||||
assert.ok(revAt < bootAt);
|
||||
const bootSlice = source.slice(bootAt, bootAt + 2800);
|
||||
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
|
||||
assert.match(
|
||||
bootSlice,
|
||||
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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 { JSDOM } from "jsdom";
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
// Exercise actual vault save/load/JSON import and sync boundaries, including
|
||||
// the asynchronous encrypted-field adapter (without a native safeStorage key).
|
||||
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
|
||||
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
|
||||
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
const mount = async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
await act(async () => { await state!.readPersistedHosts(); });
|
||||
};
|
||||
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
try {
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true]]);
|
||||
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
|
||||
if (created.ok === false) throw Error(created.error);
|
||||
assert.equal(created.ok, true);
|
||||
const standard = { ...created.host, id: "standard" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
|
||||
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
|
||||
// A later deliberate false must survive migration, including another boot.
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
|
||||
const expected = [["legacy", false], ["standard", false], ["copy", true]];
|
||||
const exported = JSON.stringify(state!.exportData());
|
||||
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
// Use the real sync payload builder and apply importer, not JSON alone.
|
||||
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), expected);
|
||||
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
|
||||
// Older cloud snapshots enter through the same per-record migration.
|
||||
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
|
||||
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), [["cloud-legacy", true]]);
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false]]);
|
||||
// These drafts enter HostDetailsPanel as initialData, then the real vault
|
||||
// save/normalization path. They must not be mistaken for legacy records.
|
||||
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
|
||||
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
|
||||
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
|
||||
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
|
||||
type: "draft" as const, draft: { kind: "host" as const, value: {
|
||||
hostname: `import-${choice}.test`, protocol: "ssh",
|
||||
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
|
||||
} },
|
||||
})));
|
||||
assert.deepEqual(imported.errors, []);
|
||||
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
|
||||
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
|
||||
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
+422
-112
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
@@ -61,11 +62,23 @@ import {
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
mergeConnectionLogsFromStorage,
|
||||
acknowledgePendingConnectionLogWrites,
|
||||
mergeConnectionLogsWithPendingMutations,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
rebaseQueuedConnectionLogs,
|
||||
retainNewestUnsavedReplays,
|
||||
pruneTerminalDataMapForStorage,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
type PendingConnectionLogWrite,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import {
|
||||
tryLoadAll as tryLoadConnectionLogTerminalData,
|
||||
migrateLegacyConnectionLogTerminalData,
|
||||
writeTerminalDataMapDiff,
|
||||
type ConnectionLogTerminalDataRev,
|
||||
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
deleteSelectedSnippetsFromVault,
|
||||
@@ -227,6 +240,13 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
|
||||
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
|
||||
const first = await tryLoadConnectionLogTerminalData();
|
||||
if (first) return first;
|
||||
return tryLoadConnectionLogTerminalData();
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
@@ -255,9 +275,6 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
|
||||
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
|
||||
|
||||
const readPersistedConnectionLogIds = (): Set<string> => {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
return new Set(stored.map((log) => log.id));
|
||||
@@ -276,23 +293,18 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
logs: ConnectionLog[],
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
export type UseVaultStateOptions = {
|
||||
/**
|
||||
* When false, skip loading and cross-window sync of the bulky
|
||||
* connection-log terminal-data side store (~30–50 MB in localStorage).
|
||||
* Pass false from windows that never show connection-log replay
|
||||
* (Settings, tray panel, terminal popup). Default true.
|
||||
*/
|
||||
syncConnectionLogTerminalData?: boolean;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -418,67 +430,158 @@ export const useVaultState = () => {
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogsRef = useRef<ConnectionLog[]>([]);
|
||||
const connectionLogWriteGenerationRef = useRef(0);
|
||||
const connectionLogPendingRef = useRef(new Map<string, PendingConnectionLogWrite>());
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
|
||||
const connectionLogTerminalDataLoadSeq = useRef(0);
|
||||
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const pluginCredentialCatalogVersion = useRef(0);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
|
||||
const run = terminalDataQueueRef.current.then(task, task);
|
||||
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}, []);
|
||||
|
||||
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
|
||||
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
|
||||
const payload: ConnectionLogTerminalDataRev = {
|
||||
rev: Math.max(prev + 1, Date.now()),
|
||||
ids: [...ids].sort(),
|
||||
nonce: crypto.randomUUID(),
|
||||
};
|
||||
// Record the exact JSON before setItem so this window ignores its own echo.
|
||||
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
|
||||
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
|
||||
if (!written) {
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = raw;
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...map,
|
||||
...connectionLogTerminalDataRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { usePersistedLogIdsFromDisk?: boolean },
|
||||
) => {
|
||||
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
|
||||
const localMaps = [
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
];
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
if (!syncConnectionLogTerminalData) return true;
|
||||
await absorbRemoteTerminalDataIfNeeded();
|
||||
const baseline = connectionLogTerminalDataPersistedRef.current;
|
||||
const merged = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
baseline,
|
||||
[
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
],
|
||||
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
logs,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
const written = await writeTerminalDataMapDiff(baseline, merged);
|
||||
// Keep the capped map in memory even when IndexedDB rejects the write, so
|
||||
// the next close retries from the last committed baseline.
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
if (!written.changed) return true;
|
||||
if (!written.persisted) return false;
|
||||
connectionLogTerminalDataPersistedRef.current = merged;
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(merged));
|
||||
return true;
|
||||
}, [
|
||||
absorbRemoteTerminalDataIfNeeded,
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
syncConnectionLogTerminalData,
|
||||
]);
|
||||
|
||||
const persistConnectionLogState = useCallback((
|
||||
const persistConnectionLogState = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
options?: {
|
||||
pruneMainBlob?: boolean;
|
||||
removedIds?: ReadonlySet<string>;
|
||||
changedIds?: ReadonlySet<string>;
|
||||
},
|
||||
) => {
|
||||
const unsavedTerminalDataPending = logs.some(
|
||||
(log) => !log.saved && log.terminalData !== undefined,
|
||||
);
|
||||
let mainReplay: ConnectionLogTerminalDataMap = {};
|
||||
const writeMain = (value: ConnectionLog[]) => {
|
||||
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, value);
|
||||
if (written) mainReplay = buildTerminalDataMapFromLogs(value);
|
||||
return written;
|
||||
};
|
||||
|
||||
let mainPersisted = true;
|
||||
if (options?.pruneMainBlob) {
|
||||
const prunedMain = pruneConnectionLogsForStorage(logs);
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
|
||||
mainPersisted = writeMain(prunedMain);
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
mainPersisted = writeMain(logs);
|
||||
}
|
||||
}
|
||||
|
||||
let sidePersisted = true;
|
||||
let sidePersisted = false;
|
||||
if (mainPersisted || !options?.pruneMainBlob) {
|
||||
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
|
||||
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
|
||||
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.pruneMainBlob) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
// The side-store await can overlap another window's metadata write.
|
||||
// Rebase this snapshot onto the blob that exists now, immediately before
|
||||
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
const toWrite = freshLogs
|
||||
? rebaseQueuedConnectionLogs(
|
||||
logs,
|
||||
options?.removedIds ?? new Set<string>(),
|
||||
options?.changedIds ?? new Set<string>(),
|
||||
mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
freshLogs,
|
||||
connectionLogTerminalDataPersistedRef.current,
|
||||
connectionLogPendingRef.current,
|
||||
),
|
||||
freshLogs,
|
||||
)
|
||||
: logs;
|
||||
mainPersisted = writeMain(toWrite);
|
||||
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
|
||||
// IndexedDB rejected the replay after the pruned metadata write. Put the
|
||||
// buffers back on the current main blob so a restart still has them,
|
||||
// without replacing logs another window wrote during the await.
|
||||
// If that blob exceeds quota, drop older unsaved replays and retry so
|
||||
// the newest capture still has a durable copy.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
let fallbackLogs = freshLogs
|
||||
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
|
||||
: logs;
|
||||
sidePersisted = writeMain(fallbackLogs);
|
||||
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
|
||||
while (!sidePersisted && keep > 1) {
|
||||
keep = Math.max(1, Math.floor(keep / 2));
|
||||
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
|
||||
sidePersisted = writeMain(fallbackLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
@@ -490,15 +593,105 @@ export const useVaultState = () => {
|
||||
"[useVaultState] Failed to persist connection log terminal replay data side store.",
|
||||
);
|
||||
}
|
||||
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
|
||||
}, [syncConnectionLogTerminalDataMap]);
|
||||
|
||||
const commitConnectionLogs = useCallback((
|
||||
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
) => {
|
||||
const prev = connectionLogsRef.current;
|
||||
const next = recipe(prev);
|
||||
const prevById = new Map(prev.map((log) => [log.id, log]));
|
||||
const nextIds = new Set<string>();
|
||||
const generation = ++connectionLogWriteGenerationRef.current;
|
||||
for (const log of next) {
|
||||
nextIds.add(log.id);
|
||||
const before = prevById.get(log.id);
|
||||
if (!before || before !== log) {
|
||||
const previous = connectionLogPendingRef.current.get(log.id);
|
||||
const changes: Partial<ConnectionLog> = { ...previous?.changes };
|
||||
const fieldGenerations = { ...previous?.fieldGenerations };
|
||||
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
|
||||
if (!before || before[field] !== log[field]) {
|
||||
Object.assign(changes, { [field]: log[field] });
|
||||
fieldGenerations[field] = generation;
|
||||
}
|
||||
}
|
||||
connectionLogPendingRef.current.set(log.id, {
|
||||
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const log of prev) {
|
||||
if (!nextIds.has(log.id)) {
|
||||
connectionLogPendingRef.current.set(log.id, { generation, log: null });
|
||||
}
|
||||
}
|
||||
connectionLogsRef.current = next;
|
||||
setConnectionLogs(next);
|
||||
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
|
||||
// The shared lock covers the read/rebase as well as both storage writes.
|
||||
// Include uncommitted generations from failed or newer queued captures.
|
||||
await absorbRemoteTerminalDataIfNeeded();
|
||||
const pending = new Map(connectionLogPendingRef.current);
|
||||
const removedIds = new Set<string>();
|
||||
const changedIds = new Set<string>();
|
||||
for (const [id, mutation] of pending) {
|
||||
if (mutation.log === null) removedIds.add(id);
|
||||
else changedIds.add(id);
|
||||
}
|
||||
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
const memory = mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
storedBefore ?? connectionLogsRef.current,
|
||||
connectionLogTerminalDataPersistedRef.current,
|
||||
pending,
|
||||
);
|
||||
const persisted = await persistConnectionLogState(
|
||||
rebaseQueuedConnectionLogs(
|
||||
memory,
|
||||
removedIds,
|
||||
changedIds,
|
||||
memory,
|
||||
storedBefore,
|
||||
),
|
||||
{ ...options, removedIds, changedIds },
|
||||
);
|
||||
// A replay/quota failure must not retain already-committed add or
|
||||
// metadata ownership: retrying it could undo a peer bookmark/deletion.
|
||||
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
|
||||
connectionLogPendingRef.current, pending, persisted,
|
||||
);
|
||||
if (persisted.metadataPersisted || persisted.replayPersisted) {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
if (stored) {
|
||||
const merged = mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
stored,
|
||||
connectionLogTerminalDataRef.current,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
}
|
||||
}
|
||||
return persisted.metadataPersisted && persisted.replayPersisted;
|
||||
}));
|
||||
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
|
||||
|
||||
const applyConnectionLogsFromStorage = useCallback((
|
||||
prev: ConnectionLog[],
|
||||
storedLogs: ConnectionLog[],
|
||||
terminalDataMap: ConnectionLogTerminalDataMap,
|
||||
) => {
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
|
||||
return mergeConnectionLogsWithPendingMutations(
|
||||
prev,
|
||||
storedLogs,
|
||||
terminalDataMap,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Encrypt outside the lock, then under the lock prune script bindings against
|
||||
@@ -1292,63 +1485,56 @@ export const useVaultState = () => {
|
||||
...log,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setConnectionLogs((prev) => {
|
||||
void commitConnectionLogs((prev) => {
|
||||
// Keep only the last 500 non-saved entries plus all saved entries
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
return pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
});
|
||||
}, { pruneMainBlob: true });
|
||||
return newLog.id;
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
return commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
)
|
||||
), { pruneMainBlob: true });
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
)
|
||||
), { pruneMainBlob: false });
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const deleteConnectionLog = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.filter((log) => log.id !== id);
|
||||
const map = { ...connectionLogTerminalDataRef.current };
|
||||
delete map[id];
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.id !== id),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
persistConnectionLogState(saved, { pruneMainBlob: true });
|
||||
return saved;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.saved),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
// Connection logs for Vault logs section — keep App domain bags off session churn.
|
||||
useLayoutEffect(() => {
|
||||
@@ -1384,6 +1570,7 @@ export const useVaultState = () => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
@@ -1415,6 +1602,11 @@ export const useVaultState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let resolveTerminalDataReady = () => {};
|
||||
const terminalDataReady = new Promise<void>((resolve) => {
|
||||
resolveTerminalDataReady = resolve;
|
||||
});
|
||||
terminalDataReadyRef.current = terminalDataReady;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
@@ -1624,14 +1816,91 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
// Load connection logs. Replay blobs live in IndexedDB, one row per
|
||||
// log id. Windows that never render replay skip that read
|
||||
// (Settings / tray / popup).
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
let connectionLogsForBoot = savedConnectionLogs;
|
||||
let terminalDataMap: ConnectionLogTerminalDataMap = {};
|
||||
let durableTerminalData = true;
|
||||
if (syncConnectionLogTerminalData) {
|
||||
const readTerminalDataRev = () => localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
// A peer can commit replay and bump the rev while this load is in
|
||||
// flight. Capture the rev first and reload when it moves, so stamping
|
||||
// the key later does not mark an unread snapshot as consumed.
|
||||
let revSeen = readTerminalDataRev();
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
),
|
||||
remove: () => {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
terminalDataMap = loaded.map;
|
||||
let revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
}
|
||||
}
|
||||
// Another window can append a log while IndexedDB is opening. Prune
|
||||
// against that newer metadata so this boot does not delete its replay.
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? savedConnectionLogs;
|
||||
const pruned = connectionLogsForBoot
|
||||
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
|
||||
: terminalDataMap;
|
||||
// A legacy map that never reached IndexedDB is not a diff baseline.
|
||||
// Comparing against it would persist only the changed ids and then
|
||||
// drop the rest of the legacy blob on the next boot.
|
||||
const baseline = loaded.durable ? terminalDataMap : {};
|
||||
const written = await writeTerminalDataMapDiff(baseline, pruned);
|
||||
if (cancelled) return;
|
||||
if (written.persisted) {
|
||||
terminalDataMap = pruned;
|
||||
if (!loaded.durable) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
}
|
||||
}
|
||||
durableTerminalData = written.persisted || loaded.durable;
|
||||
if (loaded.migrated || (written.changed && written.persisted)) {
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
|
||||
} else {
|
||||
revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? connectionLogsForBoot;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRevRawRef.current = revSeen;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
|
||||
if (connectionLogsForBoot) {
|
||||
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
|
||||
connectionLogsRef.current = hydrated;
|
||||
setConnectionLogs(hydrated);
|
||||
}
|
||||
|
||||
// Load managed sources
|
||||
@@ -1660,6 +1929,7 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolveTerminalDataReady();
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
@@ -1672,8 +1942,15 @@ export const useVaultState = () => {
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resolveTerminalDataReady();
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
}, [
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
commitEncryptedHostsUnderVaultLock,
|
||||
syncConnectionLogTerminalData,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1874,17 +2151,22 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
// A queued peer event may predate our successful commit. Read the
|
||||
// current blob, then preserve only still-owned local generations.
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
if (!next) return;
|
||||
const merged = applyConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
next,
|
||||
connectionLogTerminalDataRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(event.newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1912,19 +2194,47 @@ export const useVaultState = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const applyTerminalDataRev = (raw: string | null) => {
|
||||
// Settings, tray, and popup mounts never replay logs and must not
|
||||
// pull the side store across windows.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const seq = ++connectionLogTerminalDataLoadSeq.current;
|
||||
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
const merged = mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
|
||||
map,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
})));
|
||||
};
|
||||
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
);
|
||||
// This window already updated connectionLogsRef before setItem. The
|
||||
// adapter notifies on a timer, so this blob can be older than a log
|
||||
// added or finalized while that write was still queued. Applying it
|
||||
// would drop that log from the ref, and the follow-up recipe would
|
||||
// record no changed id. Other windows still arrive through "storage".
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1934,7 +2244,7 @@ export const useVaultState = () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -411,6 +411,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelCatalogLoading={modelCatalogLoading}
|
||||
modelCatalogSource={modelCatalogSource}
|
||||
modelCatalogFetchedAt={modelCatalogFetchedAt}
|
||||
modelCatalogWarning={modelCatalogWarning}
|
||||
onRefreshModelCatalog={handleModelCatalogRetry}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -1090,6 +1090,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
target.agentEnv,
|
||||
target.agentCommand,
|
||||
target.codexRuntime,
|
||||
// Manual refresh (retry banner / picker button) must bypass the
|
||||
// main-side LKG-first policy and go live.
|
||||
options.force === true,
|
||||
);
|
||||
if (!result?.ok || !Array.isArray(result.models)) {
|
||||
throw new Error(result?.error || 'Failed to load SDK agent models');
|
||||
|
||||
@@ -620,6 +620,34 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
|
||||
assert.equal(cache.read('copilot')?.warning, undefined);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
|
||||
|
||||
// A degraded serve (warning) is cached and replayed within the TTL.
|
||||
await cache.refresh('claude:/opt/bin/claude', async () => ({
|
||||
currentModelId: 'claude-opus-5-5',
|
||||
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
|
||||
warning: 'live fetch failed',
|
||||
}));
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
|
||||
|
||||
// Past the TTL the entry is evicted and the next automatic load re-queries
|
||||
// main; with the LKG-first policy that round-trip returns a clean
|
||||
// last-known-good serve, so the stale banner clears.
|
||||
now = 1_150;
|
||||
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
|
||||
currentModelId: 'claude-opus-5-5',
|
||||
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
|
||||
source: 'last-known-good',
|
||||
fetchedAt: '2026-10-23T08:00:00.000Z',
|
||||
}));
|
||||
assert.equal(cleared.source, 'last-known-good');
|
||||
assert.equal(cleared.warning, undefined);
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
|
||||
});
|
||||
|
||||
// #3584 alignment: OpenCode must render build-time presets while its live
|
||||
// catalog load is pending, exactly like every other backend. The old host
|
||||
// code suppressed presets for opencode while `runtimeModelLoading` was set,
|
||||
|
||||
@@ -247,6 +247,10 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
|
||||
pruneEntries();
|
||||
const cached = entries.get(key);
|
||||
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
|
||||
// The LKG-first policy lives in main: once a persisted catalog exists
|
||||
// there, a TTL-expired non-force refresh is answered from disk with a
|
||||
// clean last-known-good serve (no live attempt, no warning), so the
|
||||
// next automatic load here clears a stale banner.
|
||||
touchEntry(key, cached);
|
||||
return Promise.resolve(cloneCatalog(cached));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
test("global completed rows and expanded folder children show cleanup paths without a failed status", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({
|
||||
MutationObserver: env.window.MutationObserver, NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement, Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
})) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter");
|
||||
const { TooltipProvider } = await import("./ui/tooltip");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const warning = "Upload completed; remove backup /remote/.netcatty-backup-file.bin manually.";
|
||||
const base = {
|
||||
fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload" as const,
|
||||
status: "completed" as const, totalBytes: 10, transferredBytes: 10, speed: 0,
|
||||
startTime: Date.now(), endTime: Date.now(), isDirectory: false,
|
||||
};
|
||||
store.publishOwner("cleanup-ui", [
|
||||
{ ...base, id: "warning-file", warning },
|
||||
{ ...base, id: "warning-folder", fileName: "folder", isDirectory: true },
|
||||
{ ...base, id: "warning-child", parentTaskId: "warning-folder", warning },
|
||||
]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const click = async (selector: string) => {
|
||||
const node = env.document.querySelector(selector);
|
||||
assert.ok(node, selector);
|
||||
await dispatchDomEvent(node, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.match(env.document.body.textContent ?? "", /Backup cleanup warnings: 1/);
|
||||
await click('button[aria-label="Show files"]');
|
||||
const notices = env.document.querySelectorAll('[data-section="sftp-transfer-warning"]');
|
||||
assert.equal(notices.length, 2);
|
||||
for (const notice of notices) assert.equal(notice.textContent, warning);
|
||||
assert.equal(env.document.querySelector('[data-transfer-status="failed"]'), null);
|
||||
assert.equal(store.getTask("warning-child")?.status, "completed");
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { SftpTransferWarning } from "./sftp/SftpTransferWarning";
|
||||
|
||||
/** Stable empty snapshot while the popover is closed — progress ticks must not
|
||||
* re-render TopTabs just to update hidden list rows. */
|
||||
@@ -350,7 +351,9 @@ const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTa
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
|
||||
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
|
||||
{task.status === "completed" && task.warning
|
||||
? <div className="min-w-0 flex-1"><SftpTransferWarning warning={task.warning} compact /></div>
|
||||
: <span className="shrink-0 font-mono text-muted-foreground">{detail}</span>}
|
||||
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
);
|
||||
@@ -658,6 +661,13 @@ function TransferRow({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.status === "completed" && task.warning && <div className="mt-1"><SftpTransferWarning warning={task.warning} /></div>}
|
||||
{childTasks.some((child) => child.status === "completed" && !!child.warning) && (
|
||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{t("sftp.transfers.cleanupWarnings", { count: childTasks.filter((child) => child.status === "completed" && !!child.warning).length })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
|
||||
{isDirParent && !expanded && displayChildren.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 { normalizeVaultOrder, sortByVaultOrder } from "../domain/vaultOrder.ts";
|
||||
import type { GroupConfig } from "../types.ts";
|
||||
import GroupDetailsPanel from "./GroupDetailsPanel.tsx";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor.tsx";
|
||||
import { AsidePanel } from "./ui/aside-panel.tsx";
|
||||
import { Input } from "./ui/input.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
import { installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function saveGroup(config: GroupConfig | undefined, newName?: string) {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const dom = installDomEnvironment();
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
const previousCancelRaf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let saved: GroupConfig | undefined;
|
||||
let savedName: string | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<TooltipProvider>
|
||||
<GroupDetailsPanel
|
||||
groupPath={config?.path ?? "new-group"}
|
||||
config={config}
|
||||
availableKeys={[]}
|
||||
identities={[]}
|
||||
allHosts={[]}
|
||||
groups={[]}
|
||||
terminalThemeId="default"
|
||||
terminalFontSize={14}
|
||||
onSave={(value, name) => { saved = value; savedName = name; }}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(HostNotesEditor).props.onChange(" Updated note ");
|
||||
if (newName) {
|
||||
renderer!.root.findAllByType(Input)[0].props.onChange({ target: { value: newName } });
|
||||
}
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(AsidePanel).props.actions.props.onClick();
|
||||
});
|
||||
assert.ok(saved, "the actual panel save handler must emit a config");
|
||||
return { saved, savedName };
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
globalThis.cancelAnimationFrame = previousCancelRaf;
|
||||
dom.cleanup();
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test("editing group notes retains its manual position through write normalization", async () => {
|
||||
const original = { path: "Fleet-0999", order: 1000 };
|
||||
const sibling = { path: "Fleet-0998", order: 2000 };
|
||||
const { saved, savedName } = await saveGroup(original);
|
||||
assert.deepEqual(saved, { ...original, notes: "Updated note" });
|
||||
assert.equal(savedName, undefined);
|
||||
// VaultView removes the old config and appends the panel's replacement.
|
||||
const written = normalizeVaultOrder([sibling, saved]);
|
||||
assert.deepEqual(sortByVaultOrder(written).map((config) => config.path), [original.path, sibling.path]);
|
||||
assert.deepEqual(original, { path: "Fleet-0999", order: 1000 });
|
||||
});
|
||||
|
||||
test("renaming a group in the details panel retains its manual position", async () => {
|
||||
const { saved, savedName } = await saveGroup({ path: "parent/old", order: 2500 }, "renamed");
|
||||
assert.equal(saved.path, "parent/renamed");
|
||||
assert.equal(saved.order, 2500);
|
||||
assert.equal(savedName, "renamed");
|
||||
});
|
||||
|
||||
test("group saves preserve zero and negative manual order values", async () => {
|
||||
for (const order of [0, -1000]) {
|
||||
const { saved } = await saveGroup({ path: "group", order });
|
||||
assert.equal(saved.order, order);
|
||||
}
|
||||
});
|
||||
|
||||
test("saving a new or unordered group leaves order assignment to the write path", async () => {
|
||||
for (const config of [undefined, { path: "existing-unordered" }]) {
|
||||
const { saved } = await saveGroup(config);
|
||||
assert.equal(Object.hasOwn(saved, "order"), false);
|
||||
assert.equal(normalizeVaultOrder([saved])[0].order, 1000);
|
||||
}
|
||||
});
|
||||
@@ -559,6 +559,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
// Editing defaults or notes must not change the group's manual position.
|
||||
...(config?.order !== undefined && { order: config.order }),
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
|
||||
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
&& state.entries.length === 0;
|
||||
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Stay mounted when the side panel parks this tree. Returning null here
|
||||
// would remount the virtual list on every Notes <-> History switch.
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
@@ -394,6 +394,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
<VariableSizeVirtualList
|
||||
ref={listRef}
|
||||
items={listRows}
|
||||
measureEnabled={isVisible}
|
||||
getItemHeight={getRowHeight}
|
||||
getItemKey={(row, index) =>
|
||||
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
|
||||
|
||||
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.sshInputCompatibility")}
|
||||
hint={t("hostDetails.sshInputCompatibility.desc")}
|
||||
enabled={form.sshInputCompatibility ?? true}
|
||||
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sshInputCompatibility.reconnect")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
os: "linux",
|
||||
authMethod: undefined,
|
||||
authPolicyVersion: 1,
|
||||
sshInputCompatibility: false,
|
||||
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { GroupNode } from "../types";
|
||||
|
||||
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
|
||||
url: "http://localhost", pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
|
||||
install(key, key === "window" ? dom.window : dom.window[key]);
|
||||
}
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const scroller = dom.window.document.getElementById("scroll")!;
|
||||
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
|
||||
const { act, createElement } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTreeView } = await import("./HostTreeView.tsx");
|
||||
const noop = () => undefined;
|
||||
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
|
||||
const common = {
|
||||
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
|
||||
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
|
||||
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
|
||||
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
|
||||
};
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
const groupConfigs = [{ path: "beta", order: -1 }];
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
|
||||
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
|
||||
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
|
||||
});
|
||||
|
||||
// Deterministic work-count assertions, not a wall-clock benchmark.
|
||||
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
|
||||
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
|
||||
name: path, path, children: {}, hosts: [],
|
||||
}));
|
||||
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
|
||||
let configVisits = 0;
|
||||
Object.defineProperty(groupConfigs, "filter", {
|
||||
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
|
||||
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
|
||||
configVisits += 1;
|
||||
return predicate(value, index, array);
|
||||
}),
|
||||
});
|
||||
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
|
||||
configVisits = 0;
|
||||
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
|
||||
assert.equal(configVisits, 3);
|
||||
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
|
||||
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
|
||||
configVisits = 0;
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
|
||||
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
|
||||
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
|
||||
? ["group:c", "group:b", "group:a"]
|
||||
: ["group:a", "group:b", "group:c"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
|
||||
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
|
||||
const parent: GroupNode = {
|
||||
...leaf("parent"),
|
||||
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
|
||||
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
|
||||
};
|
||||
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
|
||||
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
|
||||
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
|
||||
{ path: "unordered-a", order: Number.NaN },
|
||||
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
|
||||
{ path: "parent/z", order: -2 },
|
||||
];
|
||||
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
|
||||
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
|
||||
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
|
||||
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
|
||||
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
|
||||
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
|
||||
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
|
||||
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
|
||||
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
|
||||
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
|
||||
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
|
||||
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
|
||||
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
|
||||
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
|
||||
]);
|
||||
assert.equal(items[0].kind === "group" && items[0].node, parent);
|
||||
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
|
||||
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
|
||||
const changed = buildVisibleHostTreeItems({
|
||||
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
|
||||
ungroupedHosts, expandedPaths, sortMode: "manual",
|
||||
});
|
||||
assert.equal(changed[0].key, "group:tie-a");
|
||||
});
|
||||
+18
-14
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
|
||||
setSize: number;
|
||||
};
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
groupConfigs: GroupConfig[],
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
const orderByPath = new Map(
|
||||
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
|
||||
new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
orderByPath?: ReadonlyMap<string, number>,
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (sortMode === 'za') return b.name.localeCompare(a.name);
|
||||
if (sortMode === 'manual') {
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
const orderA = orderByPath?.get(a.path);
|
||||
const orderB = orderByPath?.get(b.path);
|
||||
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
|
||||
if (orderA !== undefined) return -1;
|
||||
if (orderB !== undefined) return 1;
|
||||
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
|
||||
groupConfigs: GroupConfig[];
|
||||
}): VisibleHostTreeItem[] {
|
||||
const items: VisibleHostTreeItem[] = [];
|
||||
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
|
||||
const visitGroups = (nodes: GroupNode[], depth: number) => {
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
|
||||
// Root-level ungrouped hosts are siblings of top-level groups.
|
||||
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
|
||||
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
|
||||
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
|
||||
), [groupConfigs, node.children, sortMode]);
|
||||
const sortedHosts = useMemo(
|
||||
() => sortHostTreeHosts(node.hosts, sortMode),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { Host, Identity } from "../types";
|
||||
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
|
||||
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
localStorage: dom.window.localStorage,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
|
||||
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
|
||||
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
|
||||
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const identity: Identity = {
|
||||
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
|
||||
authMethod: "password", password: "fixture-old", created: 0,
|
||||
};
|
||||
const identities = scenario.identityUsername === undefined ? [] : [identity];
|
||||
const originalIdentity = { ...identity };
|
||||
const host: Host = {
|
||||
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
|
||||
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
|
||||
password: "fixture-host-old", savePassword: false,
|
||||
...(identities.length ? { identityId: identity.id } : {}),
|
||||
};
|
||||
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
|
||||
let hosts = [host, otherHost];
|
||||
let updates = 0;
|
||||
const request = {
|
||||
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
|
||||
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
|
||||
allowSavePassword: scenario.allowSavePassword ?? true,
|
||||
};
|
||||
let queue = [request];
|
||||
const responses: unknown[][] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en">
|
||||
<KeyboardInteractiveModal request={request} onCancel={() => false}
|
||||
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
|
||||
hosts, identities, keyboardInteractiveQueue: queue,
|
||||
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
|
||||
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
responses.push(args);
|
||||
return { success: true };
|
||||
} }) },
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
|
||||
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
|
||||
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
|
||||
}), requestId, values, savePassword)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
|
||||
assert.ok(input);
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
|
||||
else {
|
||||
assert.ok(checkbox);
|
||||
assert.equal(checkbox.checked, false);
|
||||
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
|
||||
assert.ok(submit);
|
||||
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
|
||||
assert.deepEqual(queue, []);
|
||||
const saved = scenario.remember && scenario.allowSavePassword !== false;
|
||||
assert.equal(updates, saved ? 1 : 0);
|
||||
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
|
||||
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
|
||||
assert.equal(hosts[0].savePassword, saved);
|
||||
assert.equal(hosts[1], otherHost);
|
||||
assert.deepEqual(identity, originalIdentity);
|
||||
|
||||
// Exercise the reconnect launcher that builds the actual SSH bridge request.
|
||||
for (const [index, candidate] of hosts.entries()) {
|
||||
let started: { username: string; password?: string } | undefined;
|
||||
const ctx = {
|
||||
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
|
||||
sessionId: `reconnect-${index}`, terminalSettings: {},
|
||||
sessionRef: { current: null }, hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null }, fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
|
||||
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
|
||||
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
|
||||
setProgressValue: noop, setChainProgress: noop,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
|
||||
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
|
||||
writeToSession: noop, resizeSession: noop,
|
||||
},
|
||||
};
|
||||
await createTerminalSessionStarters(ctx as never).startSSH({
|
||||
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
|
||||
} as never);
|
||||
assert.ok(started);
|
||||
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
|
||||
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
|
||||
}
|
||||
// A fresh request resets the checkbox and submitting state for the next case.
|
||||
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
@@ -111,7 +112,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
path: selectedPort.trim(),
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
@@ -129,7 +130,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
hostname: selectedPort.trim(),
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
@@ -157,18 +158,15 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
|
||||
const isPortValid = isSerialEndpointPath(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -212,6 +210,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.namedPipeHint')}
|
||||
</p>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
@@ -219,32 +220,34 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Baud Rate (not used by named pipes) */}
|
||||
{!isNamedPipe && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
@@ -264,92 +267,98 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Hardware-only serial framing is not used by named pipes. */}
|
||||
{!isNamedPipe && (
|
||||
<>
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import type { Host } from '../domain/models';
|
||||
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
|
||||
|
||||
function renderPanel(path: string, hostname = path) {
|
||||
const host: Host = {
|
||||
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
|
||||
port: 115200, username: '', tags: [], os: 'linux',
|
||||
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
|
||||
};
|
||||
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
|
||||
initialData={host} onSave={() => {}} onCancel={() => {}}
|
||||
/></I18nProvider>);
|
||||
}
|
||||
|
||||
test('saved named-pipe host is editable without hardware baud controls', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
|
||||
assert.match(html, /VM must create the pipe as a server/);
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
assert.doesNotMatch(html, /text-destructive/);
|
||||
});
|
||||
|
||||
test('hardware serial host retains its baud-rate controls', () => {
|
||||
assert.match(renderPanel('COM3'), /for="baud-rate"/);
|
||||
});
|
||||
|
||||
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
});
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user