Compare commits
297
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb56936e9b | ||
|
|
6da42ebc53 | ||
|
|
f0483488e1 | ||
|
|
263133f2ea | ||
|
|
887c5779b5 | ||
|
|
27f26d8e69 | ||
|
|
30a74f7c1d | ||
|
|
631d04b654 | ||
|
|
e98e236d54 | ||
|
|
1e063dfa3c | ||
|
|
8b5b0c4416 | ||
|
|
afb0585016 | ||
|
|
6d65c89ecf | ||
|
|
2f214b8725 | ||
|
|
350917fa0e | ||
|
|
fb691318c4 | ||
|
|
9c1af5c73f | ||
|
|
555f193d23 | ||
|
|
88dd20a918 | ||
|
|
57e682dc83 | ||
|
|
2671cf9f20 | ||
|
|
672a2bf406 | ||
|
|
9f09af3bda | ||
|
|
5931eb14f0 | ||
|
|
f5e2a64dfb | ||
|
|
ca2280ea8b | ||
|
|
7e7b05620f | ||
|
|
25e0c834da | ||
|
|
1f0741698d | ||
|
|
50fda91424 | ||
|
|
fc37ac48e1 | ||
|
|
d035cbea38 | ||
|
|
b3895a477d | ||
|
|
db1223d161 | ||
|
|
14ed19d2ed | ||
|
|
a36a906292 | ||
|
|
e4eb33856b | ||
|
|
b14b65679f | ||
|
|
f8616f5d3a | ||
|
|
0cab1046cf | ||
|
|
a56d1ab785 | ||
|
|
cbe89995e6 | ||
|
|
72383a50c9 | ||
|
|
0c05a96fa1 | ||
|
|
05a773b426 | ||
|
|
da16b90e9e | ||
|
|
2796d1113a | ||
|
|
8bcad28f9d | ||
|
|
59edc87ed4 | ||
|
|
5944a02238 | ||
|
|
c743456a0b | ||
|
|
b17713ef95 | ||
|
|
56fefdd03f | ||
|
|
b7e4abb30b | ||
|
|
f715a0764d | ||
|
|
5db0f90751 | ||
|
|
336ab5ab17 | ||
|
|
63f0572e0b | ||
|
|
ce9fcdfec6 | ||
|
|
de06a6aa4d | ||
|
|
ea619555ac | ||
|
|
dcf642cf39 | ||
|
|
d40b0d3c25 | ||
|
|
714e2397a4 | ||
|
|
c4aedfa9ed | ||
|
|
ba4d3fc223 | ||
|
|
1551fa8355 | ||
|
|
18377a1fd9 | ||
|
|
1caf4a6479 | ||
|
|
a0cf2b487c | ||
|
|
056164157f | ||
|
|
e3e2dcfe02 | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
89beeb9374 | ||
|
|
02049102b6 | ||
|
|
cfa13680e6 | ||
|
|
79a9bd6df1 | ||
|
|
7856c664c3 | ||
|
|
7d8a354404 | ||
|
|
da4e80946d | ||
|
|
5d2ff1cb86 | ||
|
|
9aa899059f | ||
|
|
2eee561847 | ||
|
|
d33c0193cc | ||
|
|
643e71b17b | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b | ||
|
|
677aac33b1 | ||
|
|
24254ed737 | ||
|
|
bbcf9c01c3 | ||
|
|
ba09461676 | ||
|
|
54b6cd401f | ||
|
|
9e7c9df58e | ||
|
|
824c3838e9 | ||
|
|
35b65ef1d7 | ||
|
|
8e7c28fe1b | ||
|
|
07eb68c5e7 | ||
|
|
d81896b0d3 | ||
|
|
e1cb95ee87 | ||
|
|
ee5a4a4067 | ||
|
|
3e5d450a1e | ||
|
|
823db94cf3 | ||
|
|
174c525cd4 | ||
|
|
3cfc7db371 | ||
|
|
6cc7ab9501 | ||
|
|
4cbb34517a | ||
|
|
2278681787 | ||
|
|
31d61e3040 | ||
|
|
52d4af2a86 | ||
|
|
dc6e645fab | ||
|
|
f0897c3c17 | ||
|
|
8d826594d3 | ||
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
f1410f8bb1 | ||
|
|
a4999fdf1d | ||
|
|
41313293e1 | ||
|
|
730fa643ba | ||
|
|
e907336ef5 | ||
|
|
d16e21c848 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
e6dd68b209 | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
eb6772355e | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
8eefd9de96 | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
739cab2965 | ||
|
|
42a9826603 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
d5cb8cfd3b | ||
|
|
dc45d03c7b | ||
|
|
58c6a216ac | ||
|
|
2a9c310498 | ||
|
|
9290debe91 | ||
|
|
6cf931ac4d | ||
|
|
c4433d4961 | ||
|
|
9b853130ca | ||
|
|
4213d8e672 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
59721f9717 | ||
|
|
dd41f9d8d0 | ||
|
|
e3cc23bca8 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 | ||
|
|
ab8764a317 | ||
|
|
7e72ae65ad | ||
|
|
f2e878858f | ||
|
|
2ff86c2217 | ||
|
|
5e6278cbc7 | ||
|
|
83952aac0d | ||
|
|
7ec6473112 | ||
|
|
17e6834f74 | ||
|
|
9ed8bf76dd | ||
|
|
91b8927c56 | ||
|
|
3cdc97782e | ||
|
|
1e5e77fb9c | ||
|
|
f868221b5f | ||
|
|
fd45463d98 | ||
|
|
fdeeaf74bb | ||
|
|
ffabb9e9c7 | ||
|
|
eb2c4b1611 | ||
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
392a094297 | ||
|
|
f300d583b2 | ||
|
|
618257e593 | ||
|
|
cde2be236e | ||
|
|
342941b618 | ||
|
|
8e4dd0c052 | ||
|
|
3a96cd2537 | ||
|
|
0a692dfc72 | ||
|
|
70d58a0cad | ||
|
|
1d369bf322 | ||
|
|
0d77a2e56c | ||
|
|
b92177ec42 | ||
|
|
d0e2c905cb | ||
|
|
5f06fd3cec | ||
|
|
d4ca123999 | ||
|
|
54481b6306 | ||
|
|
c11cde9bb2 | ||
|
|
3ac4772087 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
71d455d5de | ||
|
|
2838f90b03 | ||
|
|
ff3734d161 | ||
|
|
494470810a | ||
|
|
185eac3849 | ||
|
|
bf91894ab5 | ||
|
|
4a831f41c2 | ||
|
|
b39f7a3244 | ||
|
|
9af123e5d7 | ||
|
|
e5bd294cdd | ||
|
|
b48567d448 | ||
|
|
933bc93fc3 | ||
|
|
45ed193579 | ||
|
|
f471a17a75 | ||
|
|
b9293a7bb5 | ||
|
|
257660bcc1 | ||
|
|
eaf8c9a385 | ||
|
|
ff42d0c1b9 | ||
|
|
0adb7b822e | ||
|
|
311765eaa1 | ||
|
|
d2a8398b7a | ||
|
|
141148ae0d | ||
|
|
69307cee16 | ||
|
|
4445c292ea | ||
|
|
6fad272afe | ||
|
|
a7f8480da1 | ||
|
|
8d15a29bc6 | ||
|
|
e7e57f2a52 | ||
|
|
ab2f95837e | ||
|
|
f3d04a1912 | ||
|
|
f4a6b205ad | ||
|
|
614b7c997a | ||
|
|
aaef0d2ee1 | ||
|
|
5128b51702 | ||
|
|
f5c951f9a1 | ||
|
|
fcb6723c3d | ||
|
|
8de33d9b5a | ||
|
|
c727cca643 | ||
|
|
519c24cc4c | ||
|
|
ba4fab5679 | ||
|
|
27e5a10bfc | ||
|
|
3efabf2b0f | ||
|
|
74a2f99b08 | ||
|
|
80525d9b2d | ||
|
|
741ea4e9c7 | ||
|
|
4336f8fd53 | ||
|
|
aa8989c515 | ||
|
|
b6b45fc11a | ||
|
|
d5fd56ecdf | ||
|
|
d601d3d238 | ||
|
|
e20f2a9986 | ||
|
|
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 | ||
|
|
ade10ee985 | ||
|
|
cbcec92d32 | ||
|
|
5ded1f5308 |
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
|
||||
|
||||
|
||||
@@ -49,9 +49,21 @@ jobs:
|
||||
- name: Verify Codex App Server schema
|
||||
run: npm run check:codex-app-server-schema
|
||||
|
||||
- name: Verify plugin contract and runtime boundaries
|
||||
run: |
|
||||
npm run check:plugin-contract
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-examples
|
||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||
|
||||
- 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 +103,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 +150,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
|
||||
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime (internal preview)
|
||||
## Plugin host runtime
|
||||
|
||||
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
|
||||
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
|
||||
The plugin host under `electron/plugins/` exposes browser-only packages in normal
|
||||
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
|
||||
granting their permissions. Admission and default placement share
|
||||
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
|
||||
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
|
||||
types still come only from `packages/plugin-contract/schema/`; do not add a
|
||||
second private RPC shape when extending the host.
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -86,10 +89,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginPermissionHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
<PluginHostActionHost />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
const {
|
||||
hosts,
|
||||
hostsRef,
|
||||
identities,
|
||||
keyboardInteractiveQueue,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
@@ -424,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));
|
||||
@@ -1164,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,
|
||||
@@ -1183,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,
|
||||
|
||||
@@ -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';
|
||||
@@ -255,6 +256,7 @@ export function AppSideEffects() {
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
@@ -714,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]);
|
||||
@@ -1458,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,
|
||||
@@ -1476,7 +1478,9 @@ export function AppSideEffects() {
|
||||
: resolveEffectiveHost;
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
connectToHost: workspaceId
|
||||
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
|
||||
: connectToHost,
|
||||
discoveredShells,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
@@ -1487,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);
|
||||
@@ -1497,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}".` };
|
||||
}
|
||||
@@ -1511,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,
|
||||
|
||||
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
@@ -167,7 +169,8 @@ function AppPluginKeybindingThemeHost({
|
||||
sessions: any[];
|
||||
workspaces: any[];
|
||||
}) {
|
||||
const appThemeStyle = useAppThemeStyle();
|
||||
// View environment is global in the current host: publish committed colors only.
|
||||
const appThemeStyle = useAppThemeStyle(false);
|
||||
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
|
||||
const pluginThemeTokens = useMemo(
|
||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||
@@ -855,6 +858,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||
<PluginAgentHost sessions={sessions} />
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||
|
||||
@@ -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" }));
|
||||
@@ -59,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -143,6 +144,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -171,6 +173,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||
* vars, never the App shell.
|
||||
*/
|
||||
export function useAppThemeStyle(): React.CSSProperties {
|
||||
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||
return useMemo(() => {
|
||||
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||
includePreview,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
|
||||
}
|
||||
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
||||
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
|
||||
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.renameChat': 'Rename chat',
|
||||
'ai.chat.renameUsage': 'Usage: /rename <new title>',
|
||||
'ai.chat.renameNoActive': 'No active chat to rename.',
|
||||
'ai.chat.renameDone': 'Chat renamed to "{title}".',
|
||||
'ai.chat.exportSessions': 'Export AI chats',
|
||||
'ai.chat.importSessions': 'Import AI chats',
|
||||
'ai.chat.exportDone': 'AI chats exported to {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'AI chats exported.',
|
||||
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
|
||||
'ai.chat.importDone': 'Imported {n} AI chat(s).',
|
||||
'ai.chat.importNone': 'No AI chats found in the selected file.',
|
||||
'ai.chat.chatsHeader': 'Chats visible in this panel:',
|
||||
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Dissociated',
|
||||
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
|
||||
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
|
||||
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
|
||||
"ai.toolSettings.privacyOn": "Text redaction enabled",
|
||||
"ai.toolSettings.privacyOff": "Text redaction disabled",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
|
||||
"ai.toolSettings.configurePrivacy": "Configure privacy",
|
||||
"ai.toolSettings.unsaved": "Unsaved changes",
|
||||
"ai.toolSettings.viewSkills": "View CLI guidance",
|
||||
"ai.toolSettings.viewPrompt": "View setup prompt",
|
||||
"ai.toolSettings.behavior": "MCP behavior",
|
||||
"ai.toolSettings.configureBehavior": "Configure behavior",
|
||||
"ai.toolSettings.configureClients": "Configure clients",
|
||||
"ai.toolSettings.httpOn": "HTTP enabled",
|
||||
"ai.toolSettings.httpOff": "HTTP disabled",
|
||||
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configure HTTP",
|
||||
"ai.toolSettings.hideSecret": "Hide secret",
|
||||
"ai.toolSettings.showSecret": "Show secret",
|
||||
"ai.toolSettings.manualSetup": "Manual setup",
|
||||
"ai.toolSettings.format": "Format",
|
||||
"ai.toolSettings.connectionDetails": "Connection details",
|
||||
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
|
||||
"ai.toolSettings.retry": "Retry",
|
||||
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
|
||||
"settings.plugins.landing.updateAvailable": "Version {version} available",
|
||||
"settings.plugins.landing.loading": "Loading installed plugins…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
|
||||
"settings.plugins.landing.options": "Plugin options",
|
||||
"settings.plugins.landing.manager": "Plugin manager",
|
||||
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
|
||||
"settings.plugins.landing.browse": "Browse",
|
||||
"settings.plugins.landing.refresh": "Refresh installed plugins",
|
||||
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
|
||||
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
|
||||
"settings.plugins.landing.search": "Search installed plugins",
|
||||
"settings.plugins.landing.settingsFor": "Settings for {name}",
|
||||
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
|
||||
"settings.plugins.landing.sourceUnknown": "Source unknown",
|
||||
"settings.plugins.landing.status.running": "Running",
|
||||
"settings.plugins.landing.status.starting": "Starting",
|
||||
"settings.plugins.landing.status.stopping": "Stopping",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "Quarantined",
|
||||
"settings.plugins.landing.status.unknown": "Status unknown",
|
||||
"settings.plugins.landing.status.idle": "Enabled, idle",
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
@@ -135,7 +161,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.tempDirChoose': 'Choose folder',
|
||||
'settings.system.tempDirManual': 'Enter an absolute folder path',
|
||||
'settings.system.tempDirApply': 'Apply',
|
||||
'settings.system.tempDirReset': 'Reset to default',
|
||||
'settings.system.tempDirError': 'Unable to use this folder.',
|
||||
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
@@ -556,6 +587,8 @@ Highlight the focused split pane:
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
@@ -964,9 +997,53 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'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',
|
||||
@@ -1191,8 +1268,140 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
'settings.plugins.title': 'Plugin settings',
|
||||
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.author.official": "Official maintainer",
|
||||
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
"settings.plugins.manager.enabled": "Enabled",
|
||||
"settings.plugins.manager.disabled": "Disabled",
|
||||
"settings.plugins.manager.enable": "Enable",
|
||||
"settings.plugins.manager.disable": "Disable",
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.sidebar.extensions": "Extension pages",
|
||||
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
|
||||
"plugins.sidebar.loading": "Loading extension page…",
|
||||
"plugins.vault.loading": "Loading plugin page…",
|
||||
"plugins.vault.extensions": "Extensions",
|
||||
"plugins.vault.back": "Back to hosts",
|
||||
"plugins.interaction.title": "Plugin request",
|
||||
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
|
||||
"plugins.interaction.input": "Input text",
|
||||
"plugins.interaction.filter": "Filter choices",
|
||||
"plugins.interaction.preview": "Preview clipboard",
|
||||
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
|
||||
"plugins.interaction.previewAction": "Preview clipboard",
|
||||
"plugins.interaction.share": "Share with plugin",
|
||||
"plugins.interaction.copy": "Copy to clipboard",
|
||||
"plugins.interaction.failed": "This request expired or could not be completed.",
|
||||
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
|
||||
"settings.plugins.permissions.allow": "Allow",
|
||||
"settings.plugins.permissions.ask": "Ask",
|
||||
"settings.plugins.permissions.deny": "Deny",
|
||||
"settings.plugins.permissions.title": "Permissions",
|
||||
"settings.plugins.permissions.refresh": "Refresh",
|
||||
"settings.plugins.permissions.loading": "Loading permissions...",
|
||||
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
||||
"settings.plugins.permissions.always": "Until revoked",
|
||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||
"settings.plugins.permissions.session": "Until the session closes",
|
||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
|
||||
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
|
||||
"settings.plugins.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.themes.title": "Plugin themes",
|
||||
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
|
||||
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
|
||||
"settings.plugins.themes.choose": "Choose a theme",
|
||||
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
|
||||
"settings.plugins.themes.applied": "Applied",
|
||||
"settings.plugins.themes.preview": "Preview",
|
||||
"settings.plugins.themes.apply": "Apply",
|
||||
"settings.plugins.themes.cancel": "Cancel preview",
|
||||
"settings.plugins.themes.restore": "Restore default",
|
||||
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
|
||||
"settings.plugins.themes.failed": "Could not apply theme.",
|
||||
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
|
||||
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
|
||||
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.manager.details": "View details",
|
||||
"settings.plugins.manager.noDescription": "No description provided.",
|
||||
"settings.plugins.status": "Notices: {count}",
|
||||
"settings.plugins.info": "Plugin information",
|
||||
"settings.plugins.manager.noVaultShort": "Secret storage unavailable",
|
||||
"settings.plugins.market.offlineShort": "Offline catalog · installation unavailable",
|
||||
"settings.plugins.market.readmeInfo": "About this README",
|
||||
"settings.plugins.market.installed": "Installed",
|
||||
"settings.plugins.market.sourceBadge": "Source example",
|
||||
"settings.plugins.market.title": "Marketplace",
|
||||
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
|
||||
"settings.plugins.market.back": "All plugins",
|
||||
"settings.plugins.market.install": "Install this version (disabled)",
|
||||
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
|
||||
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
|
||||
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
|
||||
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
|
||||
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
|
||||
"settings.plugins.market.retry": "Retry",
|
||||
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
|
||||
"settings.plugins.market.emptyReadme": "This README is empty.",
|
||||
"settings.plugins.market.search": "Search name, author or description",
|
||||
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
|
||||
"settings.plugins.market.manifest": "Compatibility and permissions",
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
|
||||
"settings.plugins.openView": "Open",
|
||||
"settings.plugins.openManager": "Open plugin manager",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Official",
|
||||
"settings.plugins.sources.short.thirdparty": "Third-party",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
|
||||
@@ -1221,5 +1430,20 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
|
||||
'plugins.hostActions.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
'plugins.hostActions.fieldDiff': 'Changes to save',
|
||||
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
|
||||
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
|
||||
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
|
||||
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
|
||||
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
|
||||
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
|
||||
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
|
||||
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
|
||||
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.systemDisks": "System partitions ({count})",
|
||||
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
|
||||
@@ -879,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',
|
||||
@@ -508,6 +525,8 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
|
||||
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
@@ -570,6 +589,28 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.renameChat': 'Renombrar chat',
|
||||
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
|
||||
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
|
||||
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
|
||||
'ai.chat.exportSessions': 'Exportar chats de IA',
|
||||
'ai.chat.importSessions': 'Importar chats de IA',
|
||||
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
|
||||
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
|
||||
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
|
||||
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
|
||||
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
|
||||
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Desasociado',
|
||||
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
|
||||
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
|
||||
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
@@ -694,6 +735,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',
|
||||
@@ -775,4 +819,27 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
|
||||
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
|
||||
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
|
||||
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
|
||||
"ai.toolSettings.unsaved": "Cambios sin guardar",
|
||||
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
|
||||
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
|
||||
"ai.toolSettings.behavior": "Comportamiento de MCP",
|
||||
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
|
||||
"ai.toolSettings.configureClients": "Configurar clientes",
|
||||
"ai.toolSettings.httpOn": "HTTP activado",
|
||||
"ai.toolSettings.httpOff": "HTTP desactivado",
|
||||
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configurar HTTP",
|
||||
"ai.toolSettings.hideSecret": "Ocultar secreto",
|
||||
"ai.toolSettings.showSecret": "Mostrar secreto",
|
||||
"ai.toolSettings.manualSetup": "Configuración manual",
|
||||
"ai.toolSettings.format": "Formato",
|
||||
"ai.toolSettings.connectionDetails": "Detalles de conexión",
|
||||
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
|
||||
"ai.toolSettings.retry": "Reintentar",
|
||||
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
|
||||
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
|
||||
"settings.plugins.landing.loading": "Cargando plugins instalados…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
|
||||
"settings.plugins.landing.options": "Opciones de plugins",
|
||||
"settings.plugins.landing.manager": "Gestor de plugins",
|
||||
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
|
||||
"settings.plugins.landing.browse": "Explorar",
|
||||
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
|
||||
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
|
||||
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
|
||||
"settings.plugins.landing.search": "Buscar plugins instalados",
|
||||
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
|
||||
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
|
||||
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
|
||||
"settings.plugins.landing.status.running": "En ejecución",
|
||||
"settings.plugins.landing.status.starting": "Iniciando",
|
||||
"settings.plugins.landing.status.stopping": "Deteniendo",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "En cuarentena",
|
||||
"settings.plugins.landing.status.unknown": "Estado desconocido",
|
||||
"settings.plugins.landing.status.idle": "Activado, en espera",
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
@@ -84,6 +110,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',
|
||||
@@ -109,7 +161,12 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
|
||||
'settings.system.clearing': 'Limpiando...',
|
||||
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
|
||||
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
|
||||
'settings.system.tempDirChoose': 'Elegir carpeta',
|
||||
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
|
||||
'settings.system.tempDirApply': 'Aplicar',
|
||||
'settings.system.tempDirReset': 'Restablecer predeterminado',
|
||||
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
|
||||
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
|
||||
'settings.system.credentials.title': 'Protección de credenciales',
|
||||
'settings.system.credentials.status': 'Estado',
|
||||
'settings.system.credentials.checking': 'Comprobando...',
|
||||
@@ -119,6 +176,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.',
|
||||
@@ -471,6 +587,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
|
||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
@@ -1150,8 +1268,140 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
'settings.plugins.title': 'Configuración de plugins',
|
||||
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
|
||||
"settings.plugins.author.official": "Mantenimiento oficial",
|
||||
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
|
||||
"settings.plugins.manager.enabled": "Activado",
|
||||
"settings.plugins.manager.disabled": "Desactivado",
|
||||
"settings.plugins.manager.enable": "Activar",
|
||||
"settings.plugins.manager.disable": "Desactivar",
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.sidebar.extensions": "Páginas de extensiones",
|
||||
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
|
||||
"plugins.sidebar.loading": "Cargando página de extensión…",
|
||||
"plugins.vault.loading": "Cargando página del plugin…",
|
||||
"plugins.vault.extensions": "Extensiones",
|
||||
"plugins.vault.back": "Volver a hosts",
|
||||
"plugins.interaction.title": "Solicitud de complemento",
|
||||
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
|
||||
"plugins.interaction.input": "Introducir texto",
|
||||
"plugins.interaction.filter": "Filtrar opciones",
|
||||
"plugins.interaction.preview": "Vista previa del portapapeles",
|
||||
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
|
||||
"plugins.interaction.previewAction": "Vista previa del portapapeles",
|
||||
"plugins.interaction.share": "Compartir con el complemento",
|
||||
"plugins.interaction.copy": "Copiar al portapapeles",
|
||||
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
|
||||
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
|
||||
"settings.plugins.permissions.allow": "Permitir",
|
||||
"settings.plugins.permissions.ask": "Preguntar",
|
||||
"settings.plugins.permissions.deny": "Denegar",
|
||||
"settings.plugins.permissions.title": "Permisos",
|
||||
"settings.plugins.permissions.refresh": "Actualizar",
|
||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
||||
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
|
||||
"settings.plugins.permissions.always": "Hasta su revocación",
|
||||
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
|
||||
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
|
||||
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
|
||||
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
|
||||
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
|
||||
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
|
||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.themes.title": "Temas de plugins",
|
||||
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
|
||||
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
|
||||
"settings.plugins.themes.choose": "Elegir tema",
|
||||
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
|
||||
"settings.plugins.themes.applied": "Aplicado",
|
||||
"settings.plugins.themes.preview": "Vista previa",
|
||||
"settings.plugins.themes.apply": "Aplicar",
|
||||
"settings.plugins.themes.cancel": "Cancelar vista previa",
|
||||
"settings.plugins.themes.restore": "Restaurar predeterminado",
|
||||
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
|
||||
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
|
||||
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
|
||||
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
|
||||
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
|
||||
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
|
||||
"settings.plugins.manager.details": "Ver detalles",
|
||||
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
|
||||
"settings.plugins.status": "{count} avisos",
|
||||
"settings.plugins.info": "Información de plugins",
|
||||
"settings.plugins.manager.noVaultShort": "Almacenamiento de secretos no disponible",
|
||||
"settings.plugins.market.offlineShort": "Catálogo sin conexión · instalación no disponible",
|
||||
"settings.plugins.market.readmeInfo": "Acerca de este README",
|
||||
"settings.plugins.market.installed": "Instalado",
|
||||
"settings.plugins.market.sourceBadge": "Ejemplo de código",
|
||||
"settings.plugins.market.title": "Catálogo",
|
||||
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
|
||||
"settings.plugins.market.back": "Todos los plugins",
|
||||
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
|
||||
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
|
||||
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
|
||||
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
|
||||
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
|
||||
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
|
||||
"settings.plugins.market.retry": "Reintentar",
|
||||
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
|
||||
"settings.plugins.market.emptyReadme": "Este README está vacío.",
|
||||
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
|
||||
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
|
||||
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
|
||||
"settings.plugins.openView": "Abrir",
|
||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Oficial",
|
||||
"settings.plugins.sources.short.thirdparty": "Terceros",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
|
||||
@@ -1180,5 +1430,20 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
|
||||
'plugins.hostActions.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
|
||||
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
|
||||
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
|
||||
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
|
||||
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
|
||||
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
|
||||
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
|
||||
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
|
||||
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.systemDisks": "Particiones del sistema ({count})",
|
||||
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -219,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',
|
||||
@@ -675,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',
|
||||
@@ -841,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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
|
||||
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.renameChat': 'Переименовать чат',
|
||||
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
|
||||
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
|
||||
'ai.chat.renameDone': 'Чат переименован в «{title}».',
|
||||
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
|
||||
'ai.chat.importSessions': 'Импортировать чаты ИИ',
|
||||
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
|
||||
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
|
||||
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
|
||||
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
|
||||
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
|
||||
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Терминал',
|
||||
'ai.chat.chatsScopeGlobal': 'Глобально',
|
||||
'ai.chat.chatsScopeDangling': 'Связь потеряна',
|
||||
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
|
||||
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
|
||||
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
|
||||
"ai.toolSettings.privacyOn": "Скрытие текста включено",
|
||||
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
|
||||
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
|
||||
"ai.toolSettings.configurePrivacy": "Настроить приватность",
|
||||
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
|
||||
"ai.toolSettings.viewSkills": "Инструкции CLI",
|
||||
"ai.toolSettings.viewPrompt": "Инструкции подключения",
|
||||
"ai.toolSettings.behavior": "Поведение MCP",
|
||||
"ai.toolSettings.configureBehavior": "Настроить поведение",
|
||||
"ai.toolSettings.configureClients": "Настроить клиенты",
|
||||
"ai.toolSettings.httpOn": "HTTP включён",
|
||||
"ai.toolSettings.httpOff": "HTTP выключен",
|
||||
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Настроить HTTP",
|
||||
"ai.toolSettings.hideSecret": "Скрыть секрет",
|
||||
"ai.toolSettings.showSecret": "Показать секрет",
|
||||
"ai.toolSettings.manualSetup": "Ручная настройка",
|
||||
"ai.toolSettings.format": "Формат",
|
||||
"ai.toolSettings.connectionDetails": "Параметры подключения",
|
||||
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
|
||||
"ai.toolSettings.retry": "Повторить",
|
||||
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
|
||||
};
|
||||
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Очистить временные файлы',
|
||||
'settings.system.clearing': 'Очистка...',
|
||||
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
|
||||
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
|
||||
'settings.system.tempDirChoose': 'Выбрать папку',
|
||||
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
|
||||
'settings.system.tempDirApply': 'Применить',
|
||||
'settings.system.tempDirReset': 'Сбросить по умолчанию',
|
||||
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
|
||||
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
|
||||
'settings.system.credentials.title': 'Защита учётных данных',
|
||||
'settings.system.credentials.status': 'Статус',
|
||||
'settings.system.credentials.checking': 'Проверка...',
|
||||
@@ -525,6 +530,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
@@ -1130,8 +1137,136 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
'settings.plugins.title': 'Настройки плагинов',
|
||||
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
|
||||
"settings.plugins.author.official": "Официальная поддержка",
|
||||
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
|
||||
"settings.plugins.manager.enabled": "Включён",
|
||||
"settings.plugins.manager.disabled": "Отключён",
|
||||
"settings.plugins.manager.enable": "Включить",
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.sidebar.extensions": "Страницы расширений",
|
||||
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
|
||||
"plugins.sidebar.loading": "Загрузка страницы расширения…",
|
||||
"plugins.vault.loading": "Загрузка страницы плагина…",
|
||||
"plugins.vault.extensions": "Расширения",
|
||||
"plugins.vault.back": "К хостам",
|
||||
"plugins.interaction.title": "Запрос плагина",
|
||||
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
|
||||
"plugins.interaction.input": "Ввести текст",
|
||||
"plugins.interaction.filter": "Фильтр вариантов",
|
||||
"plugins.interaction.preview": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
|
||||
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.share": "Передать плагину",
|
||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
||||
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||
"settings.plugins.permissions.refresh": "Обновить",
|
||||
"settings.plugins.permissions.loading": "Загрузка разрешений...",
|
||||
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
|
||||
"settings.plugins.permissions.always": "До отзыва",
|
||||
"settings.plugins.permissions.application": "До выхода из Netcatty",
|
||||
"settings.plugins.permissions.session": "До закрытия сеанса",
|
||||
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
|
||||
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
|
||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.themes.title": "Темы плагинов",
|
||||
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
|
||||
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
|
||||
"settings.plugins.themes.choose": "Выберите тему",
|
||||
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
|
||||
"settings.plugins.themes.applied": "Применено",
|
||||
"settings.plugins.themes.preview": "Предпросмотр",
|
||||
"settings.plugins.themes.apply": "Применить",
|
||||
"settings.plugins.themes.cancel": "Отменить предпросмотр",
|
||||
"settings.plugins.themes.restore": "Сбросить тему",
|
||||
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
|
||||
"settings.plugins.themes.failed": "Не удалось применить тему.",
|
||||
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
|
||||
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
|
||||
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
|
||||
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
|
||||
"settings.plugins.manager.details": "Подробнее",
|
||||
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
|
||||
"settings.plugins.status": "Уведомления: {count}",
|
||||
"settings.plugins.info": "Информация о плагинах",
|
||||
"settings.plugins.manager.noVaultShort": "Хранилище секретов недоступно",
|
||||
"settings.plugins.market.offlineShort": "Каталог офлайн · установка недоступна",
|
||||
"settings.plugins.market.readmeInfo": "Об этом README",
|
||||
"settings.plugins.market.installed": "Установлен",
|
||||
"settings.plugins.market.sourceBadge": "Пример исходного кода",
|
||||
"settings.plugins.market.title": "Каталог",
|
||||
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
|
||||
"settings.plugins.market.back": "Все плагины",
|
||||
"settings.plugins.market.install": "Установить эту версию (отключённой)",
|
||||
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
|
||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
|
||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||
"settings.plugins.market.retry": "Повторить",
|
||||
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
|
||||
"settings.plugins.market.emptyReadme": "Этот README пуст.",
|
||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
|
||||
"settings.plugins.openView": "Открыть",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Официальный",
|
||||
"settings.plugins.sources.short.thirdparty": "Сторонний",
|
||||
"settings.plugins.sources.short.local": "Локальный",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
|
||||
@@ -1160,4 +1295,19 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
|
||||
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
|
||||
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
|
||||
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
|
||||
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
|
||||
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
|
||||
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
|
||||
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.systemDisks": "Системные разделы ({count})",
|
||||
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||
"monitor.diskIoTotal": "Всего",
|
||||
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
|
||||
@@ -886,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,29 @@ 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("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
|
||||
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
|
||||
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const note = locale.messages['plugins.hostActions.selectionNote'];
|
||||
assert.match(note, permissionWords[locale.name], locale.name);
|
||||
assert.doesNotMatch(note, oldPromise, locale.name);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.renameChat': '重命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新标题>',
|
||||
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
|
||||
'ai.chat.exportSessions': '导出 AI 会话',
|
||||
'ai.chat.importSessions': '导入 AI 会话',
|
||||
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
|
||||
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
|
||||
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
|
||||
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
|
||||
'ai.chat.chatsHeader': '此面板可见的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作区',
|
||||
'ai.chat.chatsScopeTerminal': '终端',
|
||||
'ai.chat.chatsScopeGlobal': '全局',
|
||||
'ai.chat.chatsScopeDangling': '已断开关联',
|
||||
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
|
||||
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序号>',
|
||||
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
|
||||
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
|
||||
"ai.toolSettings.privacyOn": "已启用文本脱敏",
|
||||
"ai.toolSettings.privacyOff": "未启用文本脱敏",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
|
||||
"ai.toolSettings.configurePrivacy": "配置隐私",
|
||||
"ai.toolSettings.unsaved": "有未保存的更改",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示词",
|
||||
"ai.toolSettings.behavior": "MCP 行为",
|
||||
"ai.toolSettings.configureBehavior": "配置行为",
|
||||
"ai.toolSettings.configureClients": "配置客户端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已启用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未启用",
|
||||
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "配置 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隐藏密钥",
|
||||
"ai.toolSettings.showSecret": "显示密钥",
|
||||
"ai.toolSettings.manualSetup": "手动配置",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "连接详情",
|
||||
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
|
||||
"ai.toolSettings.retry": "重试",
|
||||
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在载入已安装插件…",
|
||||
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
|
||||
"settings.plugins.landing.options": "插件选项",
|
||||
"settings.plugins.landing.manager": "插件管理器",
|
||||
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
|
||||
"settings.plugins.landing.browse": "浏览",
|
||||
"settings.plugins.landing.refresh": "刷新已安装插件",
|
||||
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
|
||||
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
|
||||
"settings.plugins.landing.search": "搜索已安装插件",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的设置",
|
||||
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
|
||||
"settings.plugins.landing.sourceUnknown": "来源未知",
|
||||
"settings.plugins.landing.status.running": "运行中",
|
||||
"settings.plugins.landing.status.starting": "启动中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "错误",
|
||||
"settings.plugins.landing.status.quarantined": "已隔离",
|
||||
"settings.plugins.landing.status.unknown": "状态未知",
|
||||
"settings.plugins.landing.status.idle": "已启用,空闲",
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
@@ -139,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.tempDirChoose': '选择文件夹',
|
||||
'settings.system.tempDirManual': '输入绝对文件夹路径',
|
||||
'settings.system.tempDirApply': '应用',
|
||||
'settings.system.tempDirReset': '恢复默认',
|
||||
'settings.system.tempDirError': '无法使用此文件夹。',
|
||||
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
@@ -560,6 +591,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
@@ -1035,6 +1068,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': '无法开始传输',
|
||||
@@ -1149,8 +1183,140 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
'settings.plugins.title': '插件设置',
|
||||
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.author.official": "官方维护",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
"settings.plugins.manager.enabled": "已启用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "启用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.sidebar.extensions": "扩展页面",
|
||||
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
|
||||
"plugins.sidebar.loading": "正在加载扩展页面…",
|
||||
"plugins.vault.loading": "正在加载插件页面…",
|
||||
"plugins.vault.extensions": "扩展",
|
||||
"plugins.vault.back": "返回主机",
|
||||
"plugins.interaction.title": "插件请求",
|
||||
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
|
||||
"plugins.interaction.input": "输入文本",
|
||||
"plugins.interaction.filter": "筛选选项",
|
||||
"plugins.interaction.preview": "剪贴板预览",
|
||||
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
|
||||
"plugins.interaction.previewAction": "预览剪贴板",
|
||||
"plugins.interaction.share": "分享给插件",
|
||||
"plugins.interaction.copy": "复制到剪贴板",
|
||||
"plugins.interaction.failed": "此请求已过期或无法完成。",
|
||||
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
|
||||
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
|
||||
"plugins.interaction.terminalTarget": "所选终端",
|
||||
"plugins.interaction.terminalPreview": "预览终端选区",
|
||||
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
|
||||
"plugins.interaction.draft": "终端输入草稿",
|
||||
"plugins.interaction.exactText": "转义控制字符后的完整文本",
|
||||
"plugins.interaction.closeDraft": "关闭草稿",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
|
||||
"settings.plugins.permissions.allow": "允许",
|
||||
"settings.plugins.permissions.ask": "询问",
|
||||
"settings.plugins.permissions.deny": "拒绝",
|
||||
"settings.plugins.permissions.title": "权限",
|
||||
"settings.plugins.permissions.refresh": "刷新",
|
||||
"settings.plugins.permissions.loading": "正在加载权限...",
|
||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||
"settings.plugins.permissions.always": "直到撤销",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到会话关闭",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
|
||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.themes.title": "插件主题",
|
||||
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
|
||||
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
|
||||
"settings.plugins.themes.choose": "选择主题",
|
||||
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
|
||||
"settings.plugins.themes.applied": "已应用",
|
||||
"settings.plugins.themes.preview": "预览",
|
||||
"settings.plugins.themes.apply": "应用",
|
||||
"settings.plugins.themes.cancel": "取消预览",
|
||||
"settings.plugins.themes.restore": "恢复默认",
|
||||
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
|
||||
"settings.plugins.themes.failed": "无法应用主题。",
|
||||
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
|
||||
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
|
||||
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
|
||||
"settings.plugins.manager.details": "查看详情",
|
||||
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
|
||||
"settings.plugins.status": "{count} 项提醒",
|
||||
"settings.plugins.info": "插件说明",
|
||||
"settings.plugins.manager.noVaultShort": "凭据存储不可用",
|
||||
"settings.plugins.market.offlineShort": "目录离线 · 暂不可安装",
|
||||
"settings.plugins.market.readmeInfo": "README 来源说明",
|
||||
"settings.plugins.market.installed": "已安装",
|
||||
"settings.plugins.market.sourceBadge": "源码示例",
|
||||
"settings.plugins.market.title": "插件市场",
|
||||
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安装此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
|
||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||
"settings.plugins.market.retry": "重试",
|
||||
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 内容为空。",
|
||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||
"settings.plugins.market.manifest": "兼容性与权限",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本地",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
|
||||
@@ -1179,5 +1345,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
'plugins.hostActions.fieldDiff': '即将保存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
|
||||
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
|
||||
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
|
||||
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
|
||||
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.systemDisks": "系统分区({count})",
|
||||
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
|
||||
"monitor.diskIoTotal": "汇总",
|
||||
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
|
||||
@@ -969,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': '通过主机代理',
|
||||
|
||||
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
||||
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
'ai.chat.renameChat': '重新命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新標題>',
|
||||
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
|
||||
'ai.chat.exportSessions': '匯出 AI 會話',
|
||||
'ai.chat.importSessions': '匯入 AI 會話',
|
||||
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
|
||||
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
|
||||
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
|
||||
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
|
||||
'ai.chat.chatsHeader': '此面板可見的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作區',
|
||||
'ai.chat.chatsScopeTerminal': '終端',
|
||||
'ai.chat.chatsScopeGlobal': '全域',
|
||||
'ai.chat.chatsScopeDangling': '已斷開關聯',
|
||||
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
|
||||
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序號>',
|
||||
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
|
||||
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
|
||||
'ai.chat.justNow': '剛剛',
|
||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
|
||||
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
|
||||
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
|
||||
"ai.toolSettings.configurePrivacy": "設定隱私",
|
||||
"ai.toolSettings.unsaved": "有未儲存的變更",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示詞",
|
||||
"ai.toolSettings.behavior": "MCP 行為",
|
||||
"ai.toolSettings.configureBehavior": "設定行為",
|
||||
"ai.toolSettings.configureClients": "設定用戶端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已啟用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未啟用",
|
||||
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "設定 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隱藏金鑰",
|
||||
"ai.toolSettings.showSecret": "顯示金鑰",
|
||||
"ai.toolSettings.manualSetup": "手動設定",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "連線詳情",
|
||||
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
|
||||
"ai.toolSettings.retry": "重試",
|
||||
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
|
||||
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
|
||||
"settings.plugins.landing.options": "外掛選項",
|
||||
"settings.plugins.landing.manager": "外掛管理器",
|
||||
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
|
||||
"settings.plugins.landing.browse": "瀏覽",
|
||||
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
|
||||
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
|
||||
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
|
||||
"settings.plugins.landing.search": "搜尋已安裝外掛",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的設定",
|
||||
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
|
||||
"settings.plugins.landing.sourceUnknown": "來源不明",
|
||||
"settings.plugins.landing.status.running": "執行中",
|
||||
"settings.plugins.landing.status.starting": "啟動中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "錯誤",
|
||||
"settings.plugins.landing.status.quarantined": "已隔離",
|
||||
"settings.plugins.landing.status.unknown": "狀態不明",
|
||||
"settings.plugins.landing.status.idle": "已啟用,閒置",
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.cancel': '取消',
|
||||
@@ -141,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理臨時檔案',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
|
||||
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
|
||||
'settings.system.tempDirChoose': '選擇資料夾',
|
||||
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
|
||||
'settings.system.tempDirApply': '套用',
|
||||
'settings.system.tempDirReset': '恢復預設',
|
||||
'settings.system.tempDirError': '無法使用此資料夾。',
|
||||
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
|
||||
'settings.system.credentials.title': '憑證保護',
|
||||
'settings.system.credentials.status': '狀態',
|
||||
'settings.system.credentials.checking': '檢查中...',
|
||||
@@ -562,6 +593,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
@@ -1037,6 +1070,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': '無法開始傳輸',
|
||||
@@ -1152,9 +1186,141 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
'settings.plugins.title': '外掛程式設定',
|
||||
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
|
||||
"settings.plugins.author.official": "官方維護",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
|
||||
"settings.plugins.manager.enabled": "已啟用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "啟用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.sidebar.extensions": "擴充功能頁面",
|
||||
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
|
||||
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.extensions": "擴充功能",
|
||||
"plugins.vault.back": "返回主機",
|
||||
"plugins.interaction.title": "外掛請求",
|
||||
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
|
||||
"plugins.interaction.input": "輸入文字",
|
||||
"plugins.interaction.filter": "篩選選項",
|
||||
"plugins.interaction.preview": "剪貼簿預覽",
|
||||
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
|
||||
"plugins.interaction.previewAction": "預覽剪貼簿",
|
||||
"plugins.interaction.share": "分享給外掛",
|
||||
"plugins.interaction.copy": "複製到剪貼簿",
|
||||
"plugins.interaction.failed": "此請求已過期或無法完成。",
|
||||
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
|
||||
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
|
||||
"plugins.interaction.terminalTarget": "所選終端",
|
||||
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
|
||||
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
|
||||
"plugins.interaction.draft": "終端輸入草稿",
|
||||
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
|
||||
"plugins.interaction.closeDraft": "關閉草稿",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
|
||||
"settings.plugins.permissions.allow": "允許",
|
||||
"settings.plugins.permissions.ask": "詢問",
|
||||
"settings.plugins.permissions.deny": "拒絕",
|
||||
"settings.plugins.permissions.title": "權限",
|
||||
"settings.plugins.permissions.refresh": "重新整理",
|
||||
"settings.plugins.permissions.loading": "正在載入權限...",
|
||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||
"settings.plugins.permissions.always": "直到撤銷",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
|
||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.themes.title": "外掛主題",
|
||||
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
|
||||
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
|
||||
"settings.plugins.themes.choose": "選擇主題",
|
||||
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
|
||||
"settings.plugins.themes.applied": "已套用",
|
||||
"settings.plugins.themes.preview": "預覽",
|
||||
"settings.plugins.themes.apply": "套用",
|
||||
"settings.plugins.themes.cancel": "取消預覽",
|
||||
"settings.plugins.themes.restore": "恢復預設",
|
||||
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
|
||||
"settings.plugins.themes.failed": "無法套用主題。",
|
||||
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本機",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
|
||||
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
|
||||
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
|
||||
"settings.plugins.manager.details": "查看詳情",
|
||||
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
|
||||
"settings.plugins.status": "{count} 項提醒",
|
||||
"settings.plugins.info": "外掛說明",
|
||||
"settings.plugins.manager.noVaultShort": "憑據儲存無法使用",
|
||||
"settings.plugins.market.offlineShort": "目錄離線 · 暫時無法安裝",
|
||||
"settings.plugins.market.readmeInfo": "README 來源說明",
|
||||
"settings.plugins.market.installed": "已安裝",
|
||||
"settings.plugins.market.sourceBadge": "原始碼範例",
|
||||
"settings.plugins.market.title": "插件市場",
|
||||
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安裝此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
|
||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||
"settings.plugins.market.retry": "重試",
|
||||
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 內容為空。",
|
||||
"settings.plugins.market.search": "搜索名稱、作者或描述",
|
||||
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
|
||||
"settings.plugins.market.manifest": "兼容性與權限",
|
||||
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
|
||||
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
|
||||
'settings.plugins.pressKeybinding': '請按下組合鍵',
|
||||
@@ -1182,4 +1348,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
'plugins.hostActions.fieldDiff': '即將儲存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
|
||||
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
|
||||
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
|
||||
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
|
||||
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.systemDisks": "系統分割區({count})",
|
||||
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
|
||||
"monitor.diskIoTotal": "彙總",
|
||||
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
|
||||
@@ -969,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': '透過主機代理',
|
||||
|
||||
@@ -2,14 +2,9 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DebouncedBackupMirror,
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
shouldRestoreFromBackup,
|
||||
} from './aiSessionsBackup';
|
||||
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
|
||||
|
||||
function makeSession(id: string, updatedAt = 1_000): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
|
||||
assert.equal(isMissingOrCorruptSessionsStore(null), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore(''), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
|
||||
for (const raw of [null, undefined, '', '{}', 'not-json']) {
|
||||
assert.deepEqual(readLiveSessionsArray(raw), []);
|
||||
}
|
||||
const sessions = [makeSession('s1')];
|
||||
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
|
||||
});
|
||||
|
||||
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
|
||||
assert.equal(parseBackupSessions(null), null);
|
||||
assert.equal(parseBackupSessions(''), null);
|
||||
assert.equal(parseBackupSessions('not json'), null);
|
||||
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
|
||||
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
|
||||
const sessions = parseBackupSessions(JSON.stringify([
|
||||
{ id: 's1' },
|
||||
{ nope: true },
|
||||
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
|
||||
{ ...makeSession('null-scope'), scope: null },
|
||||
{ ...makeSession('missing-title'), title: undefined },
|
||||
{ ...makeSession('missing-agent'), agentId: undefined },
|
||||
{ ...makeSession('null-message'), messages: [null] },
|
||||
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
|
||||
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
|
||||
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
|
||||
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
|
||||
null,
|
||||
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
|
||||
]));
|
||||
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
test('malformed imports cannot reach pruning or inject broken sessions', () => {
|
||||
const current = [makeSession('live')];
|
||||
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
|
||||
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
|
||||
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
|
||||
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
|
||||
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
|
||||
});
|
||||
|
||||
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
|
||||
const backup = JSON.stringify([makeSession('s1')]);
|
||||
// Empty (or wiped) store -> restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('', backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('[]', backup), false);
|
||||
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
|
||||
// Non-empty live store -> never restore (explicit deletions must win).
|
||||
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
|
||||
// Empty/corrupt backup -> nothing to restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, null), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
|
||||
});
|
||||
|
||||
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
|
||||
const current = [makeSession('keep', 100), makeSession('older', 100)];
|
||||
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
|
||||
const merged = mergeImportedSessions(current, imported);
|
||||
const byId = new Map(merged.map((session) => [session.id, session]));
|
||||
assert.equal(byId.get('older')?.updatedAt, 100);
|
||||
assert.equal(byId.get('newer')?.updatedAt, 200);
|
||||
assert.equal(byId.has('added'), true);
|
||||
assert.equal(byId.has('keep'), true);
|
||||
// Sorted newest-first for stable history ordering.
|
||||
assert.deepEqual(
|
||||
merged.map((session) => session.updatedAt),
|
||||
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
|
||||
);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.schedule('{"gen":2}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
mirror.schedule('{"gen":3}');
|
||||
mirror.schedule('{"gen":4}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
// A second flush with nothing pending is a no-op.
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
|
||||
assert.deepEqual(readLiveSessionsArray(null), []);
|
||||
assert.deepEqual(readLiveSessionsArray(''), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('not-json'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('[]'), []);
|
||||
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Renderer-side logic for the AI session durability layer (issue #4015 R2
|
||||
* follow-up): a disk mirror of the localStorage session store plus a
|
||||
* restore-on-empty-store boot path. The IPC plumbing lives in
|
||||
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
|
||||
* decisions pure so they can be tested without Electron.
|
||||
*/
|
||||
|
||||
/** Debounce window: at most one backup write per mutation burst (~2s). */
|
||||
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface AiSessionsBackupBridge {
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
/** Restore only when the live store is missing or malformed. A valid empty
|
||||
* array represents an intentional deletion and must remain authoritative. */
|
||||
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the live sessions array from a raw localStorage string. Returns [] when
|
||||
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
|
||||
* treat a non-array as AISession[]. */
|
||||
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a backup payload into sessions. Returns null when the payload is
|
||||
* corrupt or malformed (ignored gracefully); also drops entries without a
|
||||
* usable required fields. Backup generations are written by the same serializer as the
|
||||
* live store, so entries are expected to be full AISession objects.
|
||||
*/
|
||||
|
||||
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
|
||||
if (typeof json !== 'string' || json === '') return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const sessions = parsed.filter(
|
||||
(entry): entry is AISession => (
|
||||
typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as AISession).id === 'string'
|
||||
&& typeof (entry as AISession).title === 'string'
|
||||
&& typeof (entry as AISession).agentId === 'string'
|
||||
&& typeof (entry as AISession).scope === 'object'
|
||||
&& (entry as AISession).scope !== null
|
||||
&& typeof (entry as AISession).scope.type === 'string'
|
||||
&& Array.isArray((entry as AISession).messages)
|
||||
&& (entry as AISession).messages.every(message => (
|
||||
typeof message === 'object' && message !== null
|
||||
&& typeof message.id === 'string'
|
||||
&& typeof message.role === 'string'
|
||||
&& typeof message.content === 'string'
|
||||
&& typeof message.timestamp === 'number'
|
||||
&& Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof (entry as AISession).updatedAt === 'number'
|
||||
&& Number.isFinite((entry as AISession).updatedAt)
|
||||
),
|
||||
);
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
|
||||
* the backup actually contains at least one session.
|
||||
*/
|
||||
export function shouldRestoreFromBackup(
|
||||
liveRaw: string | null | undefined,
|
||||
backupJson: string | null | undefined,
|
||||
): boolean {
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
|
||||
const sessions = parseBackupSessions(backupJson);
|
||||
return sessions != null && sessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an imported backup into the current store: entries are keyed by id
|
||||
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
|
||||
* the live store (unlike the boot restore, which is empty-only).
|
||||
*/
|
||||
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
|
||||
const byId = new Map<string, AISession>();
|
||||
for (const session of current) byId.set(session.id, session);
|
||||
for (const session of imported) {
|
||||
const existing = byId.get(session.id);
|
||||
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
|
||||
byId.set(session.id, session);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced mirror scheduler. One rolling timer per process: the last
|
||||
* mutation batch wins, and the write happens `debounceMs` after the batch
|
||||
* settles (at most once per batch, per the durability spec).
|
||||
*/
|
||||
export class DebouncedBackupMirror {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingJson: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly write: (json: string) => void,
|
||||
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
|
||||
) {}
|
||||
|
||||
schedule(json: string) {
|
||||
this.pendingJson = json;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
flushNow() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pendingJson = null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
rebindActiveSessionMap,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
// Issue #4015 R2: the dropped chat is marked dangling but its record and
|
||||
// its active pointer stay intact so /chats can offer a rebind.
|
||||
const persistedSessions = latestAISessionsSnapshot;
|
||||
assert.ok(persistedSessions);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const dangling = {
|
||||
...makeSession('dangling-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
|
||||
};
|
||||
const live = {
|
||||
...makeSession('live-chat', 2, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'open' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([dangling, live]);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open']));
|
||||
|
||||
let persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
|
||||
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
|
||||
// The active pointer survives the dangling marking.
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
assert.ok(writeString.mock.callCount() > 0);
|
||||
|
||||
// The scope target becomes live again → the dangling flag clears.
|
||||
read.mock.mockImplementation(() => null);
|
||||
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
|
||||
persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const orphan = {
|
||||
...makeSession('orphan-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'gone' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([orphan]);
|
||||
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
|
||||
|
||||
try {
|
||||
// Empty is a valid observed live set (nothing mounted), not "unknown".
|
||||
cleanupOrphanedAISessions(new Set());
|
||||
const persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
|
||||
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
|
||||
await import('./aiStateSnapshots');
|
||||
__resetBackupMirrorForTests();
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
aiSessionsBackupWrite: async (payload: { json: string }) => {
|
||||
backupWrites.push(payload.json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
// Throttled: nothing has been written to disk yet.
|
||||
assert.equal(backupWrites.length, 0);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
// The mirror carries exactly the JSON that landed in localStorage.
|
||||
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
|
||||
// Failed store writes never schedule a mirror.
|
||||
writeString.mock.mockImplementation(() => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
|
||||
const cleaned = rebindActiveSessionMap(
|
||||
{
|
||||
'terminal:old-ws': 'chat-1',
|
||||
'terminal:other': 'chat-2',
|
||||
'workspace:keep': 'chat-1',
|
||||
},
|
||||
'chat-1',
|
||||
'terminal:new-ws',
|
||||
);
|
||||
assert.deepEqual(cleaned, {
|
||||
'terminal:other': 'chat-2',
|
||||
'terminal:new-ws': 'chat-1',
|
||||
});
|
||||
// Idempotent when already bound to the target scope with no other pointers.
|
||||
assert.deepEqual(
|
||||
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
|
||||
{ 'terminal:new-ws': 'chat-1' },
|
||||
);
|
||||
});
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -35,6 +36,11 @@ export interface AIBridge {
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
/** Durability mirror: write one timestamped backup generation to disk. */
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
export function getAIBridge() {
|
||||
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
/** Last live terminal/workspace target ids observed by orphan cleanup. */
|
||||
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
|
||||
// instead of stripping its record or pointer. `/chats` lists dangling chats
|
||||
// with a rebind affordance; the flag clears when the chat is rebound or its
|
||||
// scope target becomes live again.
|
||||
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
|
||||
let sessionsMarked = false;
|
||||
const markedSessions = currentSessions.map((session) => {
|
||||
if (orphanedIds.has(session.id)) {
|
||||
if (session.scopeDangling) return session;
|
||||
sessionsMarked = true;
|
||||
return { ...session, scopeDangling: true };
|
||||
}
|
||||
if (
|
||||
session.scopeDangling
|
||||
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
|
||||
) {
|
||||
sessionsMarked = true;
|
||||
const { scopeDangling: _cleared, ...rest } = session;
|
||||
return rest as AISession;
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
// Issue #4015 R2: keep active-pointer entries for scopes whose target
|
||||
// closed. Clearing them used to make dangling chats invisible (rank 0) and
|
||||
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
const prunedScopedState = pruneInactiveScopedState(
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
if (scopeKey in prunedScopedState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
if (scopeKey in prunedScopedState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. The exact JSON that landed in
|
||||
// localStorage is mirrored (already byte-budgeted), throttled to at most one
|
||||
// write per mutation batch.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
}
|
||||
});
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
// Unload and persistence must remain fail-safe.
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
|
||||
return syncedOk;
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
|
||||
scheduleBackupMirror(candidate.json);
|
||||
return true;
|
||||
}
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
|
||||
/**
|
||||
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
|
||||
* chat. Drops every pointer that still names `sessionId` under a different
|
||||
* scope key, then sets the new scope as active. Callers must persist and
|
||||
* publish the returned map (do not rebuild from a stale snapshot).
|
||||
*/
|
||||
export function rebindActiveSessionMap(
|
||||
activeSessionIdMap: Record<string, string | null>,
|
||||
sessionId: string,
|
||||
scopeKey: string,
|
||||
): Record<string, string | null> {
|
||||
let next = activeSessionIdMap;
|
||||
for (const [key, id] of Object.entries(activeSessionIdMap)) {
|
||||
if (id !== sessionId || key === scopeKey) continue;
|
||||
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
|
||||
delete next[key];
|
||||
}
|
||||
if (next[scopeKey] === sessionId) return next;
|
||||
return { ...next, [scopeKey]: sessionId };
|
||||
}
|
||||
|
||||
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
@@ -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,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
|
||||
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
|
||||
|
||||
function fixture() {
|
||||
const resources = new PluginHostActionResources();
|
||||
let active = true, authorized = 0, connections = 0, writes = 0;
|
||||
let beforeRead: (() => void) | undefined;
|
||||
let beforeCommit: (() => void) | undefined;
|
||||
let duringAuthorize: (() => void) | undefined;
|
||||
let storedSources: ManagedSource[] | undefined;
|
||||
const vault: PluginHostActionVault = {
|
||||
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
|
||||
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
|
||||
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
|
||||
const nextSources = sources(storedSources ?? vault.managedSources);
|
||||
beforeCommit?.();
|
||||
await authorize?.();
|
||||
writes++; vault.hosts = next;
|
||||
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
resources.observe(vault.hosts);
|
||||
const target = resources.metadata(synthetic.id);
|
||||
const options = {
|
||||
getVault: () => vault, resources,
|
||||
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
|
||||
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
|
||||
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
|
||||
};
|
||||
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
|
||||
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
|
||||
storedSources(value: ManagedSource[]) { storedSources = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
|
||||
});
|
||||
|
||||
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].password, synthetic.password);
|
||||
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
|
||||
assert.equal(f.vault.hosts[0].label, 'Reviewed');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
|
||||
const f = fixture(); f[point](() => f.cancel());
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
|
||||
const f = fixture();
|
||||
f.beforeRead(() => {
|
||||
if (change === 'remove') f.vault.hosts = [];
|
||||
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
|
||||
if (change === 'scripts') f.vault.snippets = [];
|
||||
if (change === 'locked') f.cancel();
|
||||
});
|
||||
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
|
||||
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
test('create produces a saved SSH draft without executable or credential injection', async () => {
|
||||
const f = fixture();
|
||||
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
|
||||
assert.equal(await executePluginHostAction(action, f.options), 'saved');
|
||||
const created = f.vault.hosts[1];
|
||||
assert.notEqual(created.id, synthetic.id);
|
||||
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
|
||||
});
|
||||
|
||||
const managedSources: ManagedSource[] = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
|
||||
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
|
||||
const f = fixture();
|
||||
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
|
||||
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
|
||||
f.vault.readPersistedHosts = async () => [structuredClone(host)];
|
||||
f.options.resources.observe(f.vault.hosts);
|
||||
const target = f.options.resources.metadata(host.id);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
|
||||
});
|
||||
|
||||
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
const action: PluginHostAction = kind === 'create'
|
||||
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
|
||||
: { kind, target: f.target, draft: { group: 'B/Child' } };
|
||||
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
|
||||
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
|
||||
assert.equal(f.vault.managedSources, managedSources);
|
||||
});
|
||||
|
||||
test('a managed-source catalog replacement during preparation prevents the write', async () => {
|
||||
const f = fixture();
|
||||
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
|
||||
const f = fixture(); f.storedSources(managedSources);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
|
||||
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
|
||||
import type { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { AppVaultRuntime } from './appRuntimeBridge';
|
||||
|
||||
// Application action model. Public RPC types live only in plugin-contract.
|
||||
export type PluginHostAction =
|
||||
| { kind: 'create'; draft: PluginHostDraftFields }
|
||||
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
|
||||
| { kind: 'connect'; target: { resourceId: string; revision: string } };
|
||||
|
||||
export interface PluginHostActionVault {
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
isInitialized: boolean;
|
||||
readPersistedHosts: () => Promise<Host[]>;
|
||||
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
|
||||
}
|
||||
|
||||
export interface PluginHostActionExecution {
|
||||
getVault: () => PluginHostActionVault;
|
||||
resources: PluginHostActionResources;
|
||||
assertCurrent: () => void;
|
||||
authorizeCommit: () => Promise<void>;
|
||||
connect: (host: Host) => unknown;
|
||||
}
|
||||
|
||||
const sameHost = (left: Host, right: Host) => (
|
||||
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
|
||||
);
|
||||
|
||||
/** Execute only a host-reviewed action. authorizeCommit claims separate,
|
||||
* one-use main-process action authority after the final asynchronous work. */
|
||||
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
|
||||
const current = () => {
|
||||
options.assertCurrent();
|
||||
const vault = options.getVault();
|
||||
if (!vault.isInitialized) throw new Error('Vault is unavailable');
|
||||
options.resources.observe(vault.hosts);
|
||||
return vault;
|
||||
};
|
||||
const initialVault = current();
|
||||
const reviewedGroups = initialVault.groupConfigs;
|
||||
const reviewedSnippets = initialVault.snippets;
|
||||
const reviewedSources = initialVault.managedSources;
|
||||
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
|
||||
const assertSelected = () => {
|
||||
const vault = current();
|
||||
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
|
||||
throw new Error('Selected host changed');
|
||||
}
|
||||
// A connection preview includes inherited and global automation. Any
|
||||
// settings change while the preview is open requires fresh confirmation.
|
||||
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
|
||||
return vault;
|
||||
};
|
||||
const persisted = await initialVault.readPersistedHosts();
|
||||
assertSelected();
|
||||
if (selected) {
|
||||
const persistedHost = persisted.find((host) => host.id === selected.id);
|
||||
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
|
||||
}
|
||||
if (action.kind === 'connect') {
|
||||
await options.authorizeCommit();
|
||||
assertSelected();
|
||||
// The existing host connection handler owns authentication, saved scripts,
|
||||
// and sessions. Its return value never reaches the sandboxed plugin.
|
||||
options.connect(selected!);
|
||||
return 'requested';
|
||||
}
|
||||
const nextHost = action.kind === 'create'
|
||||
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
|
||||
: applyPluginHostEdit(selected!, action.draft);
|
||||
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
|
||||
throw new Error('Managed SSH config changes require the Vault host editor');
|
||||
}
|
||||
const nextHosts = action.kind === 'create'
|
||||
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
|
||||
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
|
||||
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
|
||||
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
|
||||
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
|
||||
const keepSources = (sources: ManagedSource[]) => {
|
||||
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
|
||||
return sources;
|
||||
};
|
||||
const result = await initialVault.commitVaultImportTransaction(
|
||||
nextHosts, updateGroups, keepSources,
|
||||
undefined, persisted, undefined,
|
||||
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
|
||||
);
|
||||
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
|
||||
return 'saved';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
|
||||
|
||||
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const metadata = resources.metadata(first.id);
|
||||
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
|
||||
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
|
||||
resources.observe([first]);
|
||||
assert.equal(resources.resolve(first.id, metadata.revision), first);
|
||||
resources.observe([{ ...first }]);
|
||||
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
|
||||
const replaced = resources.metadata(first.id);
|
||||
resources.observe([]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
resources.observe([first]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
});
|
||||
|
||||
test('group choices are explicit current members and never expand to later members', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const selection = [resources.metadata(first.id)];
|
||||
resources.observe([first, { ...first, id: 'synthetic-2' }]);
|
||||
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
|
||||
assert.equal(resources.resolve(first.id, selection[0].revision), first);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
/** Renderer-local revisions track replacement as well as visible edits. They
|
||||
* are never authorization handles; only the main-process authority issues one. */
|
||||
export class PluginHostActionResources {
|
||||
private entries = new Map<string, { host: Host; revision: string }>();
|
||||
|
||||
observe(hosts: readonly Host[]) {
|
||||
const next = new Map<string, { host: Host; revision: string }>();
|
||||
for (const host of hosts) {
|
||||
const existing = this.entries.get(host.id);
|
||||
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
resolve(id: string, expectedRevision: string): Host {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
metadata(id: string) {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error('Selected host is unavailable');
|
||||
const host = entry.host;
|
||||
return {
|
||||
resourceId: host.id, revision: entry.revision,
|
||||
label: host.label, hostname: host.hostname, username: host.username,
|
||||
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
|
||||
group: host.group ?? '', tags: [...host.tags],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
|
||||
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
|
||||
try {
|
||||
const { document } = dom.window;
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
|
||||
const chooser = document.createElement('div');
|
||||
chooser.dataset.hostSecuritySurface = '';
|
||||
document.body.append(chooser);
|
||||
await flush(); assert.deepEqual(calls, [false]);
|
||||
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
|
||||
document.body.append(chooser); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
stop(); chooser.remove(); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
|
||||
test('hidden security surfaces do not occlude a native view', async () => {
|
||||
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
|
||||
try {
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
assert.deepEqual(calls, []);
|
||||
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, [false]); stop();
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Native plugin views must yield while a host-owned security dialog is open. */
|
||||
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
|
||||
const document = mount.ownerDocument;
|
||||
let visible = true;
|
||||
const update = () => {
|
||||
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
|
||||
const style = document.defaultView!.getComputedStyle(surface);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
|
||||
});
|
||||
if (visible === !blocked) return;
|
||||
visible = !blocked;
|
||||
publish(visible);
|
||||
};
|
||||
const observer = new document.defaultView!.MutationObserver(update);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
|
||||
update();
|
||||
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
|
||||
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
function fixture() {
|
||||
let reads = 0;
|
||||
const term = { cols: 80, element: { isConnected: true },
|
||||
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 3, y: 0 } }),
|
||||
getSelection() { reads++; return 'raw'; },
|
||||
buffer: { active: { getLine: () => ({ length: 3, translateToString: () => 'normalized' }) } },
|
||||
} as unknown as Terminal;
|
||||
const state = { term, sessionId: 'a', bootEpoch: 1, visible: true, isFocusedPane: true, connected: true, sensitive: false, normalize: false, label: 'Synthetic A' };
|
||||
const source = createPluginTerminalSelectionSource(() => state, 'renderer');
|
||||
return { term, state, source, get reads() { return reads; } };
|
||||
}
|
||||
|
||||
test('registered reader honors explicit false normalization and reads only on Preview', () => {
|
||||
const f = fixture();
|
||||
const cleanup = registerTerminalSelectionSource('a', f.source);
|
||||
try {
|
||||
assert.equal(f.reads, 0);
|
||||
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'raw');
|
||||
assert.equal(f.reads, 1);
|
||||
f.state.normalize = true;
|
||||
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'normalized');
|
||||
} finally { cleanup(); }
|
||||
});
|
||||
test('separate terminal references, hidden/attached renderer, reconnect and empty selection have no fallback', () => {
|
||||
const a = fixture(), b = fixture(); b.state.sessionId = 'b';
|
||||
const cleanupA = registerTerminalSelectionSource('a', a.source), cleanupB = registerTerminalSelectionSource('b', b.source);
|
||||
try {
|
||||
assert.throws(() => readPluginTerminalSelection('wrong', 'renderer:1'), /no longer/);
|
||||
a.term.getSelection = () => '';
|
||||
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /between 1/);
|
||||
assert.equal(b.reads, 0);
|
||||
a.state.bootEpoch++;
|
||||
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /no longer/);
|
||||
for (const field of ['visible', 'isFocusedPane', 'connected'] as const) {
|
||||
a.state[field] = false;
|
||||
assert.equal(a.source.target(), null);
|
||||
a.state[field] = true;
|
||||
}
|
||||
a.state.sensitive = true;
|
||||
assert.equal(a.source.target(), null);
|
||||
a.state.sensitive = false;
|
||||
Object.defineProperty(a.term.element, 'isConnected', { value: false });
|
||||
assert.equal(a.source.target(), null);
|
||||
} finally { cleanupA(); cleanupB(); }
|
||||
assert.throws(() => readPluginTerminalSelection('b', 'renderer:1'), /no longer/);
|
||||
});
|
||||
test('oversized ranges refuse to materialize selection and UTF-8 bytes are bounded', () => {
|
||||
const f = fixture(), cleanup = registerTerminalSelectionSource('a', f.source);
|
||||
try {
|
||||
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 100 } });
|
||||
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /bounded/);
|
||||
assert.equal(f.reads, 0);
|
||||
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 0 } });
|
||||
f.term.getSelection = () => '\u20ac'.repeat(1366);
|
||||
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /4096 bytes/);
|
||||
} finally { cleanup(); }
|
||||
});
|
||||
|
||||
test('real pane snapshots give sharing ownership to solo, Focus mode, and only the selected split pane', async () => {
|
||||
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../../components/terminalPaneVisibility');
|
||||
const workspaceById = new Map<string, import('../../types').Workspace>([
|
||||
['split', { id: 'split', title: 'Split', viewMode: 'split', focusedSessionId: 'split-a', root: { id: 'split-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'split-a' }, { id: 'b', type: 'pane', sessionId: 'split-b' }] } }],
|
||||
['focus', { id: 'focus', title: 'Focus', viewMode: 'focus', focusedSessionId: 'focus-a', root: { id: 'focus-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'focus-a' }, { id: 'b', type: 'pane', sessionId: 'focus-b' }] } }],
|
||||
]);
|
||||
const cases = [
|
||||
{ name: 'solo', activeTabId: 'solo', sessionId: 'solo', eligible: true },
|
||||
{ name: 'inactive solo', activeTabId: 'solo', sessionId: 'other', eligible: false },
|
||||
{ name: 'Focus mode', activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
|
||||
{ name: 'other Focus pane', activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
|
||||
{ name: 'selected split', activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
|
||||
{ name: 'other visible split', activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
|
||||
{ name: 'inactive workspace', activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
|
||||
{ name: 'hidden layer', activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
|
||||
];
|
||||
for (const item of cases) {
|
||||
const snapshot = getTerminalPaneRenderSnapshot({ ...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true });
|
||||
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(snapshot));
|
||||
const f = fixture();
|
||||
const state = { ...f.state, sessionId: item.sessionId, visible: inputProps.isVisible,
|
||||
isFocusedPane: inputProps.isFocusedPane };
|
||||
const source = createPluginTerminalSelectionSource(() => state, 'derived-pane');
|
||||
assert.equal(source.target() !== null, item.eligible, `${item.name}: ${snapshot}`);
|
||||
if (item.name === 'solo' || item.name === 'Focus mode') assert.equal(inputProps.isFocused, false, 'do not redefine the legacy focus bit');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
|
||||
|
||||
export interface PluginVaultPage {
|
||||
key: `plugin-vault:${string}`;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
viewId: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views
|
||||
.filter((view) => view.location === 'vault-page' && view.visible)
|
||||
.map((view): PluginVaultPage => ({
|
||||
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
|
||||
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
|
||||
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
|
||||
})))
|
||||
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
|
||||
}
|
||||
|
||||
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
|
||||
return pages.find((page) => page.key === section) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Native child views must yield to host dialogs and overlapping portals. */
|
||||
export function isPluginViewOccluded(mount: HTMLElement): boolean {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
|
||||
const overlays = mount.ownerDocument.querySelectorAll(
|
||||
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
|
||||
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
);
|
||||
return [...overlays].some((overlay) => {
|
||||
if (mount.contains(overlay)) return false;
|
||||
const style = getComputedStyle(overlay);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
|
||||
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
|
||||
const other = overlay.getBoundingClientRect();
|
||||
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
|
||||
&& other.y < rect.bottom && other.bottom > rect.y;
|
||||
});
|
||||
}
|
||||
@@ -91,6 +91,7 @@ test('session actions register and unregister', () => {
|
||||
createWorkspaceFromSessions: noop,
|
||||
addSessionToWorkspace: noop,
|
||||
appendHostToWorkspace: noop,
|
||||
appendHostToWorkspaceConfirmed: noop,
|
||||
appendLocalTerminalToWorkspace: noop,
|
||||
createWorkspaceFromTargets: noop,
|
||||
updateSplitSizes: noop,
|
||||
|
||||
@@ -57,6 +57,7 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
| 'createWorkspaceFromTargets'
|
||||
| 'updateSplitSizes'
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY) {
|
||||
setWindowOpacity(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
|
||||
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
|
||||
const nextVariant = resolveAppIconVariant(value);
|
||||
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
|
||||
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
|
||||
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
|
||||
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -523,6 +526,12 @@ export function useSettingsStorageSync({
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
setWindowOpacity(e.newValue);
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.windowBackgroundBlur) {
|
||||
setWindowBackgroundBlur(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
|
||||
const newValue = resolveAppIconVariant(e.newValue);
|
||||
if (newValue !== s.appIconVariant) {
|
||||
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
|
||||
setGlobalHotkeyEnabled,
|
||||
setHostClickBehaviorState,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
|
||||
@@ -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();
|
||||
@@ -3576,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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isMacPlatform } from '../../lib/utils';
|
||||
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
serializeWindowOpacityRecord,
|
||||
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
|
||||
persistMountedRef,
|
||||
]);
|
||||
|
||||
// Window chrome also applies in detached sessions, independently of the
|
||||
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
|
||||
// Only the windows that own settings persistence drive the shared native
|
||||
// state; detached/editor windows consume it (see applySharedWindowSetting).
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
|
||||
}, [
|
||||
enabled,
|
||||
windowBackgroundBlur,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
]);
|
||||
|
||||
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
|
||||
windowBackgroundBlur,
|
||||
windowOpacityRecord.opacity,
|
||||
);
|
||||
|
||||
// Applies in every app-content window, including detached sessions: the
|
||||
// transparent backing only looks right when the renderer class matches the
|
||||
// native vibrancy the main process put on that window.
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
|
||||
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
|
||||
return () => {
|
||||
document.documentElement.classList.remove('window-background-blur');
|
||||
document.documentElement.style.removeProperty('--window-content-opacity');
|
||||
};
|
||||
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
|
||||
|
||||
// setWindowBackgroundBlur is a main-process handler that retargets every
|
||||
// app-content window, so a single owner must drive it.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
|
||||
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
|
||||
});
|
||||
}, [enabled, windowBackgroundBlur]);
|
||||
|
||||
// Persist and sync app icon variant
|
||||
useEffect(() => {
|
||||
if (!enabled) 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 { JSDOM } from 'jsdom';
|
||||
|
||||
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
|
||||
type Harness = {
|
||||
dom: JSDOM;
|
||||
params: Parameters<typeof useSystemSettingsEffects>[0];
|
||||
bridgeRequests: boolean[];
|
||||
blurNotifications: unknown[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
|
||||
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
|
||||
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const replacements = {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
navigator: dom.window.navigator,
|
||||
localStorage: dom.window.localStorage,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true });
|
||||
}
|
||||
const bridgeRequests: boolean[] = [];
|
||||
const blurNotifications: unknown[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
|
||||
},
|
||||
});
|
||||
const noop = () => {};
|
||||
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
|
||||
enabled,
|
||||
toggleWindowHotkey: '',
|
||||
globalHotkeyEnabled: false,
|
||||
closeToTray: false,
|
||||
showTrayIcon: false,
|
||||
autoLaunchEnabled: false,
|
||||
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
|
||||
windowOpacityMutationSourceRef: { current: 'local' },
|
||||
windowBackgroundBlur,
|
||||
appIconVariant: 'original',
|
||||
autoUpdateEnabled: false,
|
||||
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
|
||||
persistMountedRef: { current: true },
|
||||
setHotkeyRegistrationError: noop,
|
||||
setAutoUpdateEnabled: noop,
|
||||
setAppIconVariant: noop,
|
||||
setAutoLaunchEnabled: noop,
|
||||
setAutoLaunchSupported: noop,
|
||||
notifySettingsChanged: (key, value) => {
|
||||
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
|
||||
},
|
||||
};
|
||||
return {
|
||||
dom,
|
||||
params,
|
||||
bridgeRequests,
|
||||
blurNotifications,
|
||||
restore: () => {
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.window.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
|
||||
useSystemSettingsEffects(props);
|
||||
return null;
|
||||
};
|
||||
|
||||
test('opacity changes and remounts preserve the chosen blur preference', async () => {
|
||||
const harness = setUp(true, true);
|
||||
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
|
||||
assert.deepEqual(bridgeRequests, [true]);
|
||||
assert.deepEqual(blurNotifications, [true]);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.deepEqual(bridgeRequests, [true, true, false]);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('detached session windows only apply the blur class, never own the setting', async () => {
|
||||
// Peer session windows run with enableSystemEffects off. They must still
|
||||
// paint the transparent backing, but persistence, cross-window broadcast and
|
||||
// the (all-windows) native vibrancy IPC stay with the settings owners.
|
||||
const harness = setUp(false, true);
|
||||
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
|
||||
assert.deepEqual(bridgeRequests, []);
|
||||
assert.deepEqual(blurNotifications, []);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
|
||||
assert.deepEqual(bridgeRequests, []);
|
||||
assert.deepEqual(blurNotifications, []);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
@@ -63,12 +64,17 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
prepareRestoredSessions,
|
||||
scheduleBackupMirror,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -77,6 +83,12 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
@@ -168,9 +180,76 @@ export function useAIState() {
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? []
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
|
||||
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
|
||||
return;
|
||||
}
|
||||
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover both persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
// marking even when the records later land in React state.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
@@ -866,6 +945,101 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
|
||||
// Rewrites `scope`, clears the dangling marker and makes the chat the
|
||||
// active session of its new scope so it shows in the scoped history again.
|
||||
const rebindSessionScope = useCallback((
|
||||
sessionId: string,
|
||||
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
|
||||
) => {
|
||||
const scopeKey = `${scope.type}:${scope.targetId}`;
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const { scopeDangling: _cleared, ...rest } = s;
|
||||
// Replace scope entirely (including hostIds) so history matching follows
|
||||
// the destination terminal/workspace rather than the chat's old host.
|
||||
const nextScope: AISession['scope'] = {
|
||||
type: scope.type,
|
||||
targetId: scope.targetId,
|
||||
};
|
||||
if (scope.hostIds && scope.hostIds.length > 0) {
|
||||
nextScope.hostIds = [...scope.hostIds];
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
scope: nextScope,
|
||||
updatedAt: Date.now(),
|
||||
} as AISession;
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
// Drop stale pointers that still reference this chat under its previous
|
||||
// scope key, then restore the active pointer for the new scope.
|
||||
// Compute + persist inside the updater so a deferred React setState still
|
||||
// writes the cleaned map (outer capture would stay null and skip persist).
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
// Open the rebound chat in this panel; active-map alone loses to an
|
||||
// explicit draft/session panelView that still points elsewhere.
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [persistSessions, setPanelViewByScope]);
|
||||
|
||||
// Manual durability escape hatch (issue #4015 follow-up): export the
|
||||
// current store to a user-chosen JSON file, or import one back in.
|
||||
// Import is an explicit action, so it may overwrite the live store.
|
||||
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupExport) return null;
|
||||
const result = await bridge.aiSessionsBackupExport({
|
||||
json: JSON.stringify(sessionsRef.current),
|
||||
});
|
||||
return result?.success ? (result.filePath ?? '') : null;
|
||||
}, []);
|
||||
|
||||
const importSessionsBackup = useCallback(async (): Promise<number> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupImport) return 0;
|
||||
const result = await bridge.aiSessionsBackupImport();
|
||||
if (!result?.success || !result.json) return 0;
|
||||
const imported = parseBackupSessions(result.json);
|
||||
if (!imported || imported.length === 0) return 0;
|
||||
// Merge synchronously against the live ref so orphan cleanup sees the
|
||||
// imported chats even if React would defer a setState updater.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = persisted;
|
||||
setLatestAISessionsSnapshot(persisted);
|
||||
setSessionsRaw(persisted);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Import does not change live terminals/workspaces, so the orphan-cleanup
|
||||
// effect will not run. Reuse the last known live target set (empty is
|
||||
// valid: nothing live) to mark chats bound to missing scopes as dangling
|
||||
// so /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? persisted;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return persisted.filter(session => importedIds.has(session.id)).length;
|
||||
}, []);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
@@ -1370,6 +1544,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -1432,6 +1609,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
||||
import { useSettingsChromeStore } from './settingsChromeStore';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
|
||||
activeTheme: TerminalTheme | null;
|
||||
applyAppTheme: () => void;
|
||||
}) {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { resolvedTheme } = useSettingsChromeStore();
|
||||
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
|
||||
const applyAppThemeRef = useRef(applyAppTheme);
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(activeTheme, applyAppTheme);
|
||||
}, [activeTheme, applyAppTheme]);
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
|
||||
import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
|
||||
|
||||
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
active: boolean;
|
||||
context: Record<string, unknown>;
|
||||
adapter: ViewAdapter;
|
||||
subscribeHidden(listener: () => void): () => void;
|
||||
unavailable(): void;
|
||||
focused?(): void;
|
||||
focusOnReveal?: boolean;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const adapterRef = useRef(adapter);
|
||||
const unavailableRef = useRef(unavailable);
|
||||
const focusedRef = useRef(focused);
|
||||
const revealFocusRef = useRef(focusOnReveal);
|
||||
const requestFocusRef = useRef<() => void>(() => {});
|
||||
revealFocusRef.current = focusOnReveal;
|
||||
focusedRef.current = focused;
|
||||
adapterRef.current = adapter;
|
||||
unavailableRef.current = unavailable;
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!active || !mount) return;
|
||||
let cancelled = false;
|
||||
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
|
||||
const instanceId = crypto.randomUUID();
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
|
||||
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
lifecycle.markViewClosed(viewId);
|
||||
void closeView(instanceId).catch(() => {});
|
||||
};
|
||||
const failCurrent = () => {
|
||||
if (cancelled) return;
|
||||
close();
|
||||
unavailableRef.current();
|
||||
};
|
||||
const unsubscribe = onViewClosed((event) => {
|
||||
lifecycle.handleHostClose(event.instanceId);
|
||||
if (event.instanceId === instanceId && !cancelled) {
|
||||
close();
|
||||
unavailableRef.current();
|
||||
}
|
||||
});
|
||||
const stopOnHide = subscribeHidden(close);
|
||||
const stopOnFocus = onViewFocused((event) => {
|
||||
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
|
||||
});
|
||||
const bounds = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
|
||||
};
|
||||
let opened = false;
|
||||
let lastBounds = '';
|
||||
let visible = false;
|
||||
const focus = () => {
|
||||
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
|
||||
};
|
||||
requestFocusRef.current = focus;
|
||||
const updateVisibility = () => {
|
||||
if (!opened || cancelled) return;
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) {
|
||||
const next = bounds();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
|
||||
}
|
||||
updateVisibility();
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
|
||||
observer?.observe(mount);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('scroll', resize, true);
|
||||
setLoading(true);
|
||||
const opening = lifecycle.beginOpen({ viewKey: viewId });
|
||||
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
|
||||
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
|
||||
await closeView(result.instanceId);
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
|
||||
resize();
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
|
||||
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
|
||||
};
|
||||
}, [active, context, slot, subscribeHidden, version, viewId]);
|
||||
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
|
||||
return { mountRef, loading };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { buildMonitorDiskListLayout, type MonitorDiskListStats } from "../../domain/monitorDisks";
|
||||
|
||||
/** Volatile per-host disclosure preference; capacity alerts always force it open. */
|
||||
export function useMonitorDiskGroups() {
|
||||
const [expandedHosts, setExpandedHosts] = useState<ReadonlySet<string>>(() => new Set());
|
||||
return {
|
||||
layoutFor: (hostId: string, stats?: MonitorDiskListStats | null) =>
|
||||
buildMonitorDiskListLayout(stats, expandedHosts.has(hostId)),
|
||||
toggle: (hostId: string) => setExpandedHosts(previous => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(hostId)) next.delete(hostId);
|
||||
else next.add(hostId);
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const latest = useRef({ activeTabId, sessions, workspaces });
|
||||
latest.current = { activeTabId, sessions, workspaces };
|
||||
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
|
||||
const [noteId, setNoteId] = useState('');
|
||||
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
|
||||
const bridge = {
|
||||
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
|
||||
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
|
||||
respondPluginAIHost: api.respondPluginAIHost.bind(api),
|
||||
};
|
||||
let selectedNoteId: string | null = null;
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
const finish = (id: string | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
|
||||
selectedNoteId = id || null;
|
||||
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
|
||||
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel };
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
setNoteId('');
|
||||
// This inventory stays inside trusted host UI. The plugin receives
|
||||
// summaries only for the Note chosen here and current selected scopes.
|
||||
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
|
||||
setChoice({ confirm: (id) => finish(id), cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
requests.get(String(request.payload.requestId))?.controller.abort();
|
||||
return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId);
|
||||
return;
|
||||
}
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController();
|
||||
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
|
||||
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
|
||||
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
void bridge.setPluginAIHostReady(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
for (const pending of requests.values()) pending.controller.abort();
|
||||
requests.clear(); runtime.shutdown();
|
||||
void bridge.setPluginAIHostReady(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice, noteId, setNoteId, noteChoices };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
|
||||
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
|
||||
|
||||
// All inventory remains in trusted application UI. Only confirmed metadata is
|
||||
// passed to the main-owned worker bindings; focus never determines a target.
|
||||
export function usePluginAgentHost(sessions: TerminalSession[]) {
|
||||
const latest = useRef(sessions); latest.current = sessions;
|
||||
const [choice, setChoice] = useState<Choice | null>(null);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
|
||||
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAgentRuntime({
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A target selection is already pending')); return; }
|
||||
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
|
||||
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
|
||||
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
|
||||
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
|
||||
}));
|
||||
const finish = (ids: string[] | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
|
||||
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
|
||||
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
|
||||
reject(Error('Selected terminal is no longer connected')); return;
|
||||
}
|
||||
resolve(selected as PluginAgentHostTerminal[]);
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
|
||||
setChoice({ items, confirm: finish, cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.subscribe((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
const pending = requests.get(request.payload.requestId);
|
||||
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request, controller.signal).then(
|
||||
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
|
||||
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
|
||||
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
|
||||
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
|
||||
const configurationChanged = (event: Event) => {
|
||||
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (key === null || (key && configurationKeys.has(key))) {
|
||||
runtime.invalidateConfiguration();
|
||||
// Invalidate main-owned backend leases too, even with no event read in
|
||||
// flight. Re-advertising availability does not renew existing handles.
|
||||
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
void bridge.ready(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
|
||||
void bridge.ready(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// All visible rows share one IPC subscription. No catalog fetch or polling here.
|
||||
const generations = new Map<string, number>();
|
||||
const listeners = new Set<() => void>();
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
|
||||
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
|
||||
for (const notify of listeners) notify();
|
||||
});
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
|
||||
}) {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
|
||||
const pluginId = plugin?.id;
|
||||
const entryId = entry?.id;
|
||||
const selectionToken = entry?.selectionToken;
|
||||
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
|
||||
? { kind: 'installed', pluginId }
|
||||
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
|
||||
[pluginId, entryId, selectionToken, sourceId]);
|
||||
const identitySource = plugin?.source?.id ?? sourceId;
|
||||
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
|
||||
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
|
||||
const installed = Boolean(plugin);
|
||||
const version = plugin?.activeVersion;
|
||||
const archiveSha256 = plugin?.archiveSha256 ?? null;
|
||||
const installedSourceId = plugin?.source?.id ?? 'local';
|
||||
useEffect(() => {
|
||||
if (!element || !request) return;
|
||||
let active = true;
|
||||
let started = false;
|
||||
const load = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
|
||||
if (!active || !value) return;
|
||||
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
|
||||
setLoaded({ key, value });
|
||||
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
|
||||
};
|
||||
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
|
||||
}, { rootMargin: '64px' });
|
||||
if (observer) observer.observe(element); else load();
|
||||
return () => { active = false; observer?.disconnect(); };
|
||||
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
|
||||
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type Raster = { light: string; dark?: string };
|
||||
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
|
||||
const pluginId = plugin?.id;
|
||||
const kind = plugin?.manifest?.icon?.kind;
|
||||
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
|
||||
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pluginId || kind !== 'package') return;
|
||||
let cancelled = false;
|
||||
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
|
||||
if (!cancelled) setLoaded({ key, value });
|
||||
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
|
||||
return () => { cancelled = true; };
|
||||
}, [key, pluginId, kind]);
|
||||
return loaded?.key === key ? loaded.value : null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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 { usePluginCatalog } from './usePluginCatalog';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
|
||||
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
|
||||
},
|
||||
} });
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
context.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginCatalog>;
|
||||
let renders = 0;
|
||||
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
|
||||
assert.equal(state.readmeStatus, 'loading');
|
||||
await act(async () => calls[0].request.reject(new Error('temporary failure')));
|
||||
assert.equal(state.readmeStatus, 'error');
|
||||
assert.match(state.readmeError!, /temporary failure/);
|
||||
await act(async () => state.retryReadme());
|
||||
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[1].request.resolve(''));
|
||||
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
|
||||
assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[3].request.resolve('# Second'));
|
||||
await act(async () => calls[2].request.reject(new Error('stale failure')));
|
||||
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.unmount());
|
||||
const unmountedRenders = renders;
|
||||
await act(async () => calls[4].request.resolve('late content'));
|
||||
assert.equal(renders, unmountedRenders);
|
||||
});
|
||||
|
||||
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
|
||||
let offline = true;
|
||||
const reads: unknown[][] = []; const installs: unknown[][] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
dispatchEvent() {}, netcatty: {
|
||||
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
|
||||
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
|
||||
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
|
||||
},
|
||||
} });
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
context.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginCatalog>;
|
||||
function Probe() { state = usePluginCatalog(entry.id); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
|
||||
assert.equal(state.source, 'cached');
|
||||
offline = false;
|
||||
await act(async () => state.retryList());
|
||||
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [listAttempt, setListAttempt] = useState(0);
|
||||
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [imageState, setImageState] = useState<{ id: string; token?: string; sourceId: string; images: Record<string, string> } | null>(null);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
|
||||
const installing = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setListStatus('loading'); setListError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog(sourceId);
|
||||
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt, sourceId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSourceBinding(null);
|
||||
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
|
||||
return () => { active = false; };
|
||||
}, [selectedId, sourceId]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
let active = true;
|
||||
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
|
||||
setReadmeState({ ...base, status: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (selectedId && selectionToken) {
|
||||
void netcattyBridge.get()?.getPluginCatalogReadmeImages?.(selectedId, selectionToken, sourceId)
|
||||
.then((images) => { if (active) setImageState({ id: selectedId, token: selectionToken, sourceId, images }); })
|
||||
.catch(() => { if (active) setImageState(null); });
|
||||
}
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, sourceId, readmeAttempt]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source, sourceBinding,
|
||||
readmeImages: imageState?.id === selectedId && imageState.token === selectionToken && imageState.sourceId === sourceId ? imageState.images : {},
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
error: installError?.id === selectedId ? installError.message : null,
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
|
||||
await bridge.resetPluginSource(id, sourceBinding.id);
|
||||
setSourceBinding(null);
|
||||
}
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
|
||||
finally { installing.current = false; if (mounted.current) setBusy(false); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
|
||||
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
|
||||
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
|
||||
closeView(instanceId: string): Promise<void>;
|
||||
focusView(instanceId: string): Promise<void>;
|
||||
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
|
||||
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
|
||||
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
}
|
||||
|
||||
export function usePluginContributions(
|
||||
@@ -196,6 +198,9 @@ export function usePluginContributions(
|
||||
if (!bridge?.closePluginView) return;
|
||||
await bridge.closePluginView(instanceId);
|
||||
}, [bridge]);
|
||||
const focusView = useCallback(async (instanceId: string) => {
|
||||
await bridge?.focusPluginView?.({ instanceId });
|
||||
}, [bridge]);
|
||||
|
||||
const setViewBounds = useCallback(async (
|
||||
instanceId: string,
|
||||
@@ -218,6 +223,9 @@ export function usePluginContributions(
|
||||
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
|
||||
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
|
||||
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
|
||||
const currentLoadState = resolvePluginContributionLoadState({
|
||||
currentQueryKey: queryKey,
|
||||
@@ -239,9 +247,11 @@ export function usePluginContributions(
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppHandlers } from '../app/appHandlersBridge';
|
||||
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
|
||||
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
|
||||
|
||||
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
|
||||
type Review = {
|
||||
prompt: VaultHostPrompt;
|
||||
candidates: Host[];
|
||||
candidateRevisions: Map<string, string>;
|
||||
customGroups: string[];
|
||||
host?: Host;
|
||||
groups: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
scriptsRun: boolean;
|
||||
claimed: boolean;
|
||||
completionToken?: string;
|
||||
};
|
||||
|
||||
export function usePluginHostActions() {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const current = useRef<Review | null>(null);
|
||||
const resources = useRef(new PluginHostActionResources());
|
||||
const responding = useRef(false);
|
||||
|
||||
const getVault = useCallback(() => {
|
||||
const vault = getAppVaultRuntime();
|
||||
const lock = getAppAppLockRuntime();
|
||||
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
|
||||
const actionState = vault.readCurrentHostActionState();
|
||||
resources.current.observe(actionState.hosts);
|
||||
return { ...vault, ...actionState };
|
||||
}, []);
|
||||
|
||||
const assertCurrent = useCallback((entry: Review) => {
|
||||
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
|
||||
const vault = getVault();
|
||||
const target = entry.prompt.vaultTarget;
|
||||
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
|
||||
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
|
||||
}, [getVault]);
|
||||
|
||||
const fail = useCallback(async (entry: Review) => {
|
||||
if (current.current !== entry) return;
|
||||
setError(true);
|
||||
try {
|
||||
await netcattyBridge.get()?.respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
|
||||
});
|
||||
} catch { /* A stopped runtime or closed window already invalidates it. */ }
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (entry: Review) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondPluginInteraction) return fail(entry);
|
||||
setBusy(true);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const target = entry.prompt.vaultTarget;
|
||||
let action: PluginHostAction;
|
||||
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
|
||||
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
|
||||
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
|
||||
} else throw new Error('Host action is invalid');
|
||||
await executePluginHostAction(action, {
|
||||
getVault, resources: resources.current,
|
||||
assertCurrent: () => assertCurrent(entry),
|
||||
authorizeCommit: async () => {
|
||||
assertCurrent(entry);
|
||||
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
|
||||
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
|
||||
entry.completionToken = receipt.completionToken;
|
||||
entry.claimed = true;
|
||||
assertCurrent(entry);
|
||||
},
|
||||
connect: (host) => {
|
||||
const connect = getAppHandlers()?.handleConnectToHost;
|
||||
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
|
||||
return connect(host);
|
||||
},
|
||||
});
|
||||
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
|
||||
} catch { await fail(entry); }
|
||||
}, [assertCurrent, fail, getVault]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.prompt.requestId !== event.requestId) return;
|
||||
current.current = null; setReview(null); setBusy(false); responding.current = false;
|
||||
return;
|
||||
}
|
||||
if (!event.request.kind.startsWith('vault.')) return;
|
||||
const prompt = event as VaultHostPrompt;
|
||||
try {
|
||||
if (prompt.phase === 'commit') {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
const vault = getVault();
|
||||
const candidates = [...vault.hosts];
|
||||
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
|
||||
const entry: Review = {
|
||||
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
|
||||
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
|
||||
managedSources: vault.managedSources,
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
|
||||
setReview(null);
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
|
||||
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
|
||||
} else setReview(entry);
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const entry = current.current;
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, fail, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
|
||||
responding.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
|
||||
const revision = entry.candidateRevisions.get(id);
|
||||
if (!revision) throw new Error('Host was not part of the current selection');
|
||||
resources.current.resolve(id, revision);
|
||||
return resources.current.metadata(id);
|
||||
}) : undefined;
|
||||
await netcattyBridge.require().respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
|
||||
...(selected === undefined ? {} : { resources: selected }),
|
||||
...(draft === undefined ? {} : { draft }),
|
||||
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
|
||||
});
|
||||
} catch { await fail(entry); }
|
||||
finally {
|
||||
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
|
||||
}
|
||||
}, [assertCurrent, fail, selectedIds]);
|
||||
|
||||
return { review, selectedIds, setSelectedIds, busy, error, respond };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
|
||||
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<UiPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt;
|
||||
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
|
||||
setPrompt(null);
|
||||
try {
|
||||
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
|
||||
});
|
||||
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
|
||||
} else setPrompt(event as UiPrompt);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
});
|
||||
void bridge.setPluginInteractionReady(true).catch(() => {});
|
||||
return () => {
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
void bridge.setPluginInteractionReady?.(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
|
||||
const active = current.current;
|
||||
if (!active || pending.current) return;
|
||||
pending.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
|
||||
const selection = action === 'preview' && active.request.kind === 'terminal.selection.read'
|
||||
? readPluginTerminalSelection(active.terminalSessionId ?? '', active.terminalRendererId ?? '') : undefined;
|
||||
const accepted = await bridge.respondPluginInteraction({
|
||||
requestId: active.requestId, actionToken: active.actionToken, action,
|
||||
...(selection === undefined ? {} : { value: selection }),
|
||||
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
|
||||
});
|
||||
if (!accepted && current.current?.actionToken === active.actionToken) {
|
||||
current.current = null; setPrompt(null);
|
||||
}
|
||||
} catch {
|
||||
if (current.current?.actionToken === active.actionToken) setError(true);
|
||||
} finally {
|
||||
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
|
||||
}
|
||||
}, [value]);
|
||||
return { prompt, value, setValue, error, busy, respond };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginLogs() {
|
||||
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
|
||||
: action === 'open' ? bridge.openPluginLogFolder?.()
|
||||
: action === 'export' ? bridge.exportPluginLogs?.()
|
||||
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
|
||||
if (!result) throw new Error('Local plugin logging is unavailable');
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (cause) {
|
||||
let message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (action === 'configure') {
|
||||
// Configuration fails closed before disk I/O. Show actual host state,
|
||||
// even when persisting a setting failed, rather than keeping stale ON.
|
||||
try {
|
||||
const actual = await netcattyBridge.require().getPluginLogStatus?.();
|
||||
if (!actual) throw new Error('Local plugin log status is unavailable');
|
||||
if (mounted.current) setStatus(actual);
|
||||
} catch (refreshError) {
|
||||
if (mounted.current) setStatus(null);
|
||||
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
|
||||
}
|
||||
}
|
||||
if (mounted.current) setError(message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void run('status');
|
||||
return () => { mounted.current = false; };
|
||||
}, [run]);
|
||||
return { status, error, busy, run };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { usePluginManager } from './usePluginManager';
|
||||
|
||||
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let fail = false;
|
||||
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
addEventListener() {}, removeEventListener() {}, netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
|
||||
},
|
||||
} });
|
||||
context.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginManager>;
|
||||
function Probe() { state = usePluginManager(); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, true);
|
||||
fail = true;
|
||||
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
|
||||
assert.equal(state.statusKnown, false);
|
||||
assert.match(state.error ?? '', /synthetic status read failed/);
|
||||
fail = false; plugin.enabled = false;
|
||||
await act(async () => { await state.refresh(); });
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, false);
|
||||
} finally { await act(async () => { renderer.unmount(); }); }
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [statusKnown, setStatusKnown] = useState(false);
|
||||
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
|
||||
if (options?.clearError) setError(null);
|
||||
const current = ++generation.current;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setStatusKnown(true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
} catch (cause) {
|
||||
if (current === generation.current) {
|
||||
setStatusKnown(false);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
update();
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
|
||||
window.addEventListener('netcatty:plugin-packages-changed', update);
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
importPackage: () => run(async (bridge) => {
|
||||
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
|
||||
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
|
||||
if (path) await bridge.installPluginPackage(path, { enable: false });
|
||||
}),
|
||||
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
|
||||
await bridge.uninstallPlugin(id);
|
||||
if (clearSource && source) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginPermissionPrompt() {
|
||||
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
|
||||
const prompt = prompts[0];
|
||||
active.current = prompt ?? null;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
|
||||
const unsubscribe = bridge.onPluginPermission((event) => {
|
||||
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
|
||||
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
|
||||
setBusy(false);
|
||||
});
|
||||
void bridge.setPluginPermissionReady(true).catch(() => {});
|
||||
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
|
||||
}, []);
|
||||
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
|
||||
if (!prompt || busy || active.current !== prompt) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
|
||||
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
|
||||
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { prompt, busy, respond };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginSources() {
|
||||
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const reload = useCallback(async () => {
|
||||
const values = await netcattyBridge.require().listPluginSources?.();
|
||||
if (!values) throw new Error('Source management unavailable');
|
||||
setSources(values);
|
||||
}, []);
|
||||
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
|
||||
const mutate = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true); setError(null);
|
||||
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
|
||||
catch (cause) { setError(String(cause)); return false; }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { sources, error, busy,
|
||||
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
|
||||
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
|
||||
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { getTerminalSelectionForClipboard } from '../../components/terminal/normalizeTerminalSelection';
|
||||
import { publishTerminalSelectionTarget, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
|
||||
type SelectionState = {
|
||||
sessionId: string | null;
|
||||
bootEpoch: number;
|
||||
term: Terminal | null;
|
||||
visible: boolean;
|
||||
isFocusedPane: boolean | undefined;
|
||||
connected: boolean;
|
||||
sensitive: boolean;
|
||||
normalize: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// No retained snapshots, PTY adapter, recorder, or AI context reader enters here.
|
||||
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
|
||||
const target = () => {
|
||||
const state = readState();
|
||||
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
|
||||
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|
||||
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
|
||||
return { sessionId: state.sessionId, rendererId: `${instanceId}:${state.bootEpoch}`, label: state.label.slice(0, 160) || 'Terminal' };
|
||||
};
|
||||
return {
|
||||
target,
|
||||
read() {
|
||||
if (!target()) throw new Error('The selected terminal is unavailable');
|
||||
const { term, normalize } = readState();
|
||||
const range = term!.getSelectionPosition();
|
||||
// Refuse a large range before asking xterm to materialize its text.
|
||||
if (!range || Math.abs(range.end.y - range.start.y) * term!.cols + Math.abs(range.end.x - range.start.x) > 4096) {
|
||||
throw new Error('Select a bounded terminal range first');
|
||||
}
|
||||
return getTerminalSelectionForClipboard(term!, normalize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginTerminalSelection(key: string, readState: () => SelectionState, lifecycle: string) {
|
||||
const latest = useRef(readState);
|
||||
latest.current = readState;
|
||||
const instance = useRef<string | null>(null);
|
||||
if (instance.current === null) instance.current = crypto.randomUUID();
|
||||
useEffect(() => registerTerminalSelectionSource(key, createPluginTerminalSelectionSource(() => latest.current(), instance.current!)), [key]);
|
||||
// The target getter reads current refs. Publication revokes old renderer/boot identities.
|
||||
useEffect(() => { publishTerminalSelectionTarget(); }, [lifecycle]);
|
||||
}
|
||||
Loaded 100 of 843 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user