mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7949242765 | ||
|
|
a0be25d107 | ||
|
|
3e454a84a5 | ||
|
|
a70e21d39b | ||
|
+1 |
16061720e6 | ||
|
|
2368fc8232 | ||
|
|
efe012e0c8 | ||
|
|
2e3dc687cb | ||
|
|
051eda6166 | ||
|
|
f5fccc0d44 | ||
|
|
3408accba7 | ||
|
|
0e3f282657 | ||
|
|
4eceeda4b7 | ||
|
|
c357d14181 | ||
|
|
e7ade527ee | ||
|
|
54c4db5030 | ||
|
|
e17d7f01b6 | ||
|
|
5098f53944 | ||
|
|
c1df8a6011 | ||
|
|
a8bb5a43fc | ||
|
|
b87360783a | ||
|
|
dc5fcfbc19 | ||
|
|
fcfba34fc1 | ||
|
|
fa554888a6 | ||
|
|
d56150953d | ||
|
|
1b9c744fda | ||
|
|
2318434ac9 | ||
|
|
dcf558e685 | ||
|
|
7683874a4e | ||
|
|
3ac24f7aa2 | ||
|
|
79a6e14f7d | ||
|
|
8043178d54 | ||
|
|
9fb2a5c8c9 | ||
|
|
70ce1258f9 | ||
|
|
c01d057a7d | ||
|
|
cd6e1c6990 | ||
|
|
42c69902e8 | ||
|
|
780d297591 | ||
|
|
b496b00d2f | ||
|
|
a8c69b8e11 | ||
|
|
6d687d570f | ||
|
|
a2f23054df | ||
|
|
0fe069e575 | ||
|
|
1809b7dc02 | ||
|
|
d5a03606b7 | ||
|
|
7cd57adcea | ||
|
|
7d5d234fdd | ||
|
|
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 | ||
|
|
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 | ||
|
|
9b853130ca | ||
|
|
e3cc23bca8 | ||
|
|
3cdc97782e | ||
|
|
eb2c4b1611 | ||
|
|
392a094297 | ||
|
|
f300d583b2 | ||
|
|
70d58a0cad | ||
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c |
No files matched your search
@@ -330,6 +330,9 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||||
run: npm run ${{ matrix.pack_script }}
|
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
|
- name: Test tray panel layout
|
||||||
run: npm run test:tray-panel-layout
|
run: npm run test:tray-panel-layout
|
||||||
|
|
||||||
@@ -631,6 +634,9 @@ jobs:
|
|||||||
done
|
done
|
||||||
npm run pack:linux-x64
|
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
|
- name: Verify packaged node-pty Linux runtime
|
||||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||||
|
|
||||||
@@ -765,6 +771,9 @@ jobs:
|
|||||||
ELECTRON_BUILDER_PUBLISH: "never"
|
ELECTRON_BUILDER_PUBLISH: "never"
|
||||||
run: npm run pack:linux-arm64
|
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
|
- name: Verify packaged node-pty Linux runtime
|
||||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm run check:plugin-contract
|
npm run check:plugin-contract
|
||||||
npm run test:plugin-runtime
|
npm run test:plugin-runtime
|
||||||
|
npm run test:plugin-examples
|
||||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm 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
|
- name: Test browser first-link lifecycle
|
||||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||||
|
|
||||||
@@ -144,5 +150,10 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run 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
|
- name: Exercise two SSH password prompts in the desktop app
|
||||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||||
@@ -170,3 +170,6 @@ build_with_vs2022.bat
|
|||||||
/resources/et/*/et.exe
|
/resources/et/*/et.exe
|
||||||
/resources/et/*/et-*-dlls/
|
/resources/et/*/et-*-dlls/
|
||||||
/resources/et/*/*.dll
|
/resources/et/*/*.dll
|
||||||
|
|
||||||
|
# Local plugin example staging and bundles
|
||||||
|
/examples/plugins/*/.package/
|
||||||
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
|
|||||||
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
|
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
|
||||||
protocol-failure containment. Runtime identity is assigned by the host and is
|
protocol-failure containment. Runtime identity is assigned by the host and is
|
||||||
never accepted from request parameters.
|
never accepted from request parameters.
|
||||||
|
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
|
||||||
|
runtime; the host validates incoming trees, styles, resources and events before
|
||||||
|
rendering. Never reintroduce native child views, iframe UI, or plugin code in
|
||||||
|
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
|
||||||
|
lifecycle, limits and verification.
|
||||||
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
|
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
|
||||||
not add another independent quit interception path.
|
not add another independent quit interception path.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { ToastProvider } from './components/ui/toast';
|
|||||||
import { TooltipProvider } from './components/ui/tooltip';
|
import { TooltipProvider } from './components/ui/tooltip';
|
||||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||||
|
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
|
||||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||||
|
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||||
@@ -48,6 +50,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
performance.mark('netcatty:renderer:app-mounted');
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<AppLocalStateProvider>
|
<AppLocalStateProvider>
|
||||||
<MonitorRuntimeHost />
|
<MonitorRuntimeHost />
|
||||||
@@ -87,11 +92,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
|||||||
<ScriptAutomationRoot />
|
<ScriptAutomationRoot />
|
||||||
<ExternalMcpApprovalsHost />
|
<ExternalMcpApprovalsHost />
|
||||||
<PluginAuthenticationHost />
|
<PluginAuthenticationHost />
|
||||||
|
<PluginPermissionHost />
|
||||||
<PluginHostInteractionHost />
|
<PluginHostInteractionHost />
|
||||||
<MultilinePasteConfirmHost />
|
<MultilinePasteConfirmHost />
|
||||||
<VaultPublisher>
|
<VaultPublisher>
|
||||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||||
<App />
|
<App />
|
||||||
|
<PluginHostActionHost />
|
||||||
</SessionPublisher>
|
</SessionPublisher>
|
||||||
</VaultPublisher>
|
</VaultPublisher>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
handleKeyboardInteractiveSubmitImpl,
|
handleKeyboardInteractiveSubmitImpl,
|
||||||
handleTrayPanelConnectRequestImpl,
|
handleTrayPanelConnectRequestImpl,
|
||||||
} from './app/AppHandlers.ts';
|
} from './app/AppHandlers.ts';
|
||||||
|
import { resolveHostAuth } from '../domain/sshAuth.ts';
|
||||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||||
import type { Host } from '../types';
|
import type { Host, Identity } from '../types';
|
||||||
|
|
||||||
const baseHost: Host = {
|
const baseHost: Host = {
|
||||||
id: 'host-1',
|
id: 'host-1',
|
||||||
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
|
|||||||
assert.equal(bridgeResponses.length, 1);
|
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 () => {
|
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||||
let hosts: Host[] = [{
|
let hosts: Host[] = [{
|
||||||
...baseHost,
|
...baseHost,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { handleCreateLocalTerminalImpl } from './AppHandlers';
|
||||||
|
|
||||||
|
type CreatedLocal = {
|
||||||
|
shell?: string;
|
||||||
|
shellArgs?: string[];
|
||||||
|
shellName?: string;
|
||||||
|
shellIcon?: string;
|
||||||
|
localStartDir?: string;
|
||||||
|
shellType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCtx(overrides?: {
|
||||||
|
localShell?: string;
|
||||||
|
localShellArgs?: string[];
|
||||||
|
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
|
||||||
|
}) {
|
||||||
|
const created: CreatedLocal[] = [];
|
||||||
|
const logs: Array<{ hostLabel: string }> = [];
|
||||||
|
const localShell = overrides?.localShell ?? '';
|
||||||
|
const localShellArgs = overrides?.localShellArgs ?? [];
|
||||||
|
const discoveredShells = overrides?.discoveredShells ?? [];
|
||||||
|
const getCtx = () => ({
|
||||||
|
addConnectionLog: (entry: { hostLabel: string }) => {
|
||||||
|
logs.push(entry);
|
||||||
|
},
|
||||||
|
classifyLocalShellType: () => 'unknown',
|
||||||
|
createLocalTerminal: (options: CreatedLocal) => {
|
||||||
|
created.push(options);
|
||||||
|
return 'session-1';
|
||||||
|
},
|
||||||
|
discoveredShells,
|
||||||
|
resolveShellSetting: (
|
||||||
|
value: string,
|
||||||
|
shells: Array<{ id: string; command: string; args?: string[] }>,
|
||||||
|
customArgs?: string[],
|
||||||
|
) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = shells.find((shell) => shell.id === value);
|
||||||
|
if (match) {
|
||||||
|
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
|
||||||
|
}
|
||||||
|
return { command: value, args: customArgs?.length ? customArgs : undefined };
|
||||||
|
},
|
||||||
|
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
|
||||||
|
terminalSettings: { localShell, localShellArgs },
|
||||||
|
});
|
||||||
|
return { getCtx, created, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
|
||||||
|
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
|
||||||
|
const { getCtx, created } = createCtx({ localShell: custom });
|
||||||
|
handleCreateLocalTerminalImpl(getCtx as never);
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.equal(created[0].shell, custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
|
||||||
|
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
|
||||||
|
const { getCtx, created } = createCtx({ localShell: custom });
|
||||||
|
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
|
||||||
|
// passes the click event as the first argument.
|
||||||
|
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
|
||||||
|
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.equal(created[0].shell, custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create local terminal still honors an explicit shell object', () => {
|
||||||
|
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
|
||||||
|
handleCreateLocalTerminalImpl(getCtx as never, {
|
||||||
|
command: String.raw`D:\portable\zsh\zsh.exe`,
|
||||||
|
args: ['-l'],
|
||||||
|
name: 'Portable Zsh',
|
||||||
|
icon: 'zsh',
|
||||||
|
});
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
|
||||||
|
assert.deepEqual(created[0].shellArgs, ['-l']);
|
||||||
|
assert.equal(created[0].shellName, 'Portable Zsh');
|
||||||
|
assert.equal(created[0].shellIcon, 'zsh');
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
|
|||||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||||
|
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||||
|
|
||||||
type AppContextGetter = () => Record<string, any>;
|
type AppContextGetter = () => Record<string, any>;
|
||||||
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
|
||||||
const isMonacoElement =
|
const isMonacoElement =
|
||||||
target instanceof HTMLElement &&
|
target instanceof HTMLElement &&
|
||||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||||
@@ -393,6 +394,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
|||||||
const {
|
const {
|
||||||
hosts,
|
hosts,
|
||||||
hostsRef,
|
hostsRef,
|
||||||
|
identities,
|
||||||
keyboardInteractiveQueue,
|
keyboardInteractiveQueue,
|
||||||
sessions,
|
sessions,
|
||||||
setKeyboardInteractiveQueue,
|
setKeyboardInteractiveQueue,
|
||||||
@@ -424,12 +426,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
|||||||
// Save password to host if requested - never for second-factor / EDR prompts
|
// Save password to host if requested - never for second-factor / EDR prompts
|
||||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||||
// host login password (#2150 / Codex review on #2151).
|
// 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) {
|
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||||
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
updateHosts(latestHosts.map((h: Host) => {
|
||||||
...h,
|
if (h.id !== host.id) return h;
|
||||||
password: savePassword,
|
const identity = h.identityId
|
||||||
savePassword: true,
|
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
|
||||||
} : h));
|
: undefined;
|
||||||
|
const detachedUsername = identity?.username?.trim();
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
password: savePassword,
|
||||||
|
savePassword: true,
|
||||||
|
...(h.identityId ? {
|
||||||
|
identityId: '',
|
||||||
|
...(detachedUsername ? { username: detachedUsername } : {}),
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
// Remove from queue by requestId
|
// Remove from queue by requestId
|
||||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||||
@@ -1121,11 +1138,18 @@ export function handleCreateLocalTerminalImpl(
|
|||||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||||
{
|
{
|
||||||
const { username, hostname } = systemInfoRef.current;
|
const { username, hostname } = systemInfoRef.current;
|
||||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
// Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
|
||||||
|
// React then passes the MouseEvent as the first arg; treat only real shell
|
||||||
|
// objects (string command) as explicit so Settings localShell still applies (#4085).
|
||||||
|
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
|
||||||
|
const resolved = explicitShell
|
||||||
|
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
const matchedShell = !explicitShell
|
||||||
const shellName = shell?.name ?? matchedShell?.name;
|
? discoveredShells.find(s => s.id === terminalSettings.localShell)
|
||||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
: undefined;
|
||||||
|
const shellName = explicitShell?.name ?? matchedShell?.name;
|
||||||
|
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
|
||||||
const sessionId = createLocalTerminal({
|
const sessionId = createLocalTerminal({
|
||||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||||
shell: resolved?.command,
|
shell: resolved?.command,
|
||||||
@@ -1255,16 +1279,14 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
||||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
|
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
|
||||||
{
|
{
|
||||||
if (hasMultipleProtocols(host)) {
|
if (hasMultipleProtocols(host)) {
|
||||||
setProtocolSelectHost(resolveEffectiveHost(host));
|
setProtocolSelectHost(resolveEffectiveHost(host));
|
||||||
setIsQuickSwitcherOpen(false);
|
setIsQuickSwitcherOpen(false);
|
||||||
setQuickSearch('');
|
|
||||||
} else {
|
} else {
|
||||||
handleConnectToHost(host);
|
handleConnectToHost(host);
|
||||||
setIsQuickSwitcherOpen(false);
|
setIsQuickSwitcherOpen(false);
|
||||||
setQuickSearch('');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,12 +139,21 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
|||||||
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
|
||||||
const source = hostSources['DialogsHost.tsx'];
|
const source = hostSources['DialogsHost.tsx'];
|
||||||
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
||||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
|
||||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||||
|
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
|
||||||
|
// dialogs bag per keystroke re-rendered the whole shell.
|
||||||
|
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QuickSwitcherOverlay owns the quick switcher query', () => {
|
||||||
|
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
|
||||||
|
assert.match(overlay, /useState\(''\)/);
|
||||||
|
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
|
||||||
|
assert.doesNotMatch(store, /quickSearch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AppSideEffects may use runtime hooks; App must not', () => {
|
test('AppSideEffects may use runtime hooks; App must not', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { registerAppHandlers } from './appHandlersBridge';
|
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
|
||||||
import { publishAppLocalUi } from './appLocalUiStore';
|
import { publishAppLocalUi } from './appLocalUiStore';
|
||||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||||
import { useAutoSync } from '../state/useAutoSync';
|
import { useAutoSync } from '../state/useAutoSync';
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '../state/connectionLogsStore';
|
} from '../state/connectionLogsStore';
|
||||||
import { getNotesSnapshot } from '../state/notesStore';
|
import { getNotesSnapshot } from '../state/notesStore';
|
||||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||||
|
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
|
||||||
|
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
|
||||||
import { useWindowControls } from '../state/useWindowControls';
|
import { useWindowControls } from '../state/useWindowControls';
|
||||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||||
@@ -59,6 +61,7 @@ import { collectSessionIds } from '../../domain/workspace';
|
|||||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||||
|
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||||
@@ -133,7 +136,6 @@ export function AppSideEffects() {
|
|||||||
| { mode: 'create' }
|
| { mode: 'create' }
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const [quickSearch, setQuickSearch] = useState('');
|
|
||||||
// Protocol selection dialog state for QuickSwitcher
|
// Protocol selection dialog state for QuickSwitcher
|
||||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||||
// Navigation state for VaultView sections
|
// Navigation state for VaultView sections
|
||||||
@@ -716,7 +718,7 @@ export function AppSideEffects() {
|
|||||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||||
|
|
||||||
// Handle keyboard-interactive submit
|
// 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
|
// Handle keyboard-interactive cancel
|
||||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
||||||
@@ -1156,6 +1158,8 @@ export function AppSideEffects() {
|
|||||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||||
|
|
||||||
|
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
|
||||||
|
|
||||||
const intent = resolveWindowCommandCloseIntent({
|
const intent = resolveWindowCommandCloseIntent({
|
||||||
activeTabId: activeTabStore.getActiveTabId(),
|
activeTabId: activeTabStore.getActiveTabId(),
|
||||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||||
@@ -1524,6 +1528,19 @@ export function AppSideEffects() {
|
|||||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||||
|
const session = sessions.find((entry) => entry.id === sessionId);
|
||||||
|
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
|
||||||
|
const session = sessions.find((entry) => entry.id === sessionId);
|
||||||
|
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
|
||||||
|
const live = terminalReconnectRegistry.inspect(sessionId);
|
||||||
|
return { ok: true, sessionId, status: session.status, waitingForInput: false,
|
||||||
|
reconnecting: false, reconnectAllowed: false, ...live };
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||||
if (!sessions.some((session) => session.id === sessionId)) {
|
if (!sessions.some((session) => session.id === sessionId)) {
|
||||||
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
||||||
@@ -1568,6 +1585,8 @@ export function AppSideEffects() {
|
|||||||
openHost: openHostForVaultAgent,
|
openHost: openHostForVaultAgent,
|
||||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||||
closeSession: closeSessionForVaultAgent,
|
closeSession: closeSessionForVaultAgent,
|
||||||
|
reconnectSession: reconnectSessionForVaultAgent,
|
||||||
|
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
|
||||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1944,7 +1963,7 @@ export function AppSideEffects() {
|
|||||||
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
|
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
|
||||||
|
|
||||||
// Handle host connect with protocol selection (used by QuickSwitcher)
|
// Handle host connect with protocol selection (used by QuickSwitcher)
|
||||||
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
||||||
|
|
||||||
// Handle protocol selection from dialog
|
// Handle protocol selection from dialog
|
||||||
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
|
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
|
||||||
@@ -2012,8 +2031,11 @@ export function AppSideEffects() {
|
|||||||
|
|
||||||
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
|
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
|
||||||
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
|
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
|
||||||
|
// Handlers are registered behind stable per-key wrappers so session/host
|
||||||
|
// churn does not re-notify every Host (see stabilizeAppHandlers).
|
||||||
|
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
registerAppHandlers({
|
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
|
||||||
// Vault glue
|
// Vault glue
|
||||||
handleAddKnownHost,
|
handleAddKnownHost,
|
||||||
handleDeleteHost,
|
handleDeleteHost,
|
||||||
@@ -2073,15 +2095,13 @@ export function AppSideEffects() {
|
|||||||
setIsCreateWorkspaceOpen,
|
setIsCreateWorkspaceOpen,
|
||||||
setIsQuickSwitcherOpen,
|
setIsQuickSwitcherOpen,
|
||||||
setProtocolSelectHost,
|
setProtocolSelectHost,
|
||||||
setQuickSearch,
|
|
||||||
handleCancelDeleteHost,
|
handleCancelDeleteHost,
|
||||||
handleConfirmDeleteHost,
|
handleConfirmDeleteHost,
|
||||||
});
|
}));
|
||||||
publishAppLocalUi({
|
publishAppLocalUi({
|
||||||
isQuickSwitcherOpen,
|
isQuickSwitcherOpen,
|
||||||
isCreateWorkspaceOpen,
|
isCreateWorkspaceOpen,
|
||||||
addToWorkspaceDialog,
|
addToWorkspaceDialog,
|
||||||
quickSearch,
|
|
||||||
protocolSelectHost,
|
protocolSelectHost,
|
||||||
navigateToSection,
|
navigateToSection,
|
||||||
deepLinkHostDraft,
|
deepLinkHostDraft,
|
||||||
@@ -2147,7 +2167,6 @@ export function AppSideEffects() {
|
|||||||
isQuickSwitcherOpen,
|
isQuickSwitcherOpen,
|
||||||
isCreateWorkspaceOpen,
|
isCreateWorkspaceOpen,
|
||||||
addToWorkspaceDialog,
|
addToWorkspaceDialog,
|
||||||
quickSearch,
|
|
||||||
protocolSelectHost,
|
protocolSelectHost,
|
||||||
navigateToSection,
|
navigateToSection,
|
||||||
deepLinkHostDraft,
|
deepLinkHostDraft,
|
||||||
|
|||||||
+55
-54
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
|
|||||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||||
|
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||||
|
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
|
||||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +46,7 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
|||||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||||
|
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
|
||||||
import {
|
import {
|
||||||
appViewDomainsEqual,
|
appViewDomainsEqual,
|
||||||
mergeAppViewDomains,
|
mergeAppViewDomains,
|
||||||
@@ -51,9 +54,6 @@ import {
|
|||||||
} from './appViewDomains';
|
} from './appViewDomains';
|
||||||
|
|
||||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||||
const LazyQuickSwitcher = lazy(() =>
|
|
||||||
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
|
||||||
);
|
|
||||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||||
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||||
);
|
);
|
||||||
@@ -61,6 +61,9 @@ const LazyTextEditorTabView = lazy(() =>
|
|||||||
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
|
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Module-level so TopTabs' memo comparator sees a stable callback.
|
||||||
|
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
|
||||||
|
|
||||||
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||||
const isVisible = useIsEditorTabActive(tabId);
|
const isVisible = useIsEditorTabActive(tabId);
|
||||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||||
@@ -240,12 +243,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
setIsCreateWorkspaceOpen,
|
setIsCreateWorkspaceOpen,
|
||||||
setIsQuickSwitcherOpen,
|
setIsQuickSwitcherOpen,
|
||||||
setProtocolSelectHost,
|
setProtocolSelectHost,
|
||||||
setQuickSearch,
|
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
const dismissTransientOverlays = useCallback(() => {
|
const dismissTransientOverlays = useCallback(() => {
|
||||||
setIsQuickSwitcherOpen(false);
|
setIsQuickSwitcherOpen(false);
|
||||||
setQuickSearch('');
|
|
||||||
setIsCreateWorkspaceOpen(false);
|
setIsCreateWorkspaceOpen(false);
|
||||||
setProtocolSelectHost(null);
|
setProtocolSelectHost(null);
|
||||||
setAddToWorkspaceDialog(null);
|
setAddToWorkspaceDialog(null);
|
||||||
@@ -258,7 +259,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
setIsCreateWorkspaceOpen,
|
setIsCreateWorkspaceOpen,
|
||||||
setIsQuickSwitcherOpen,
|
setIsQuickSwitcherOpen,
|
||||||
setProtocolSelectHost,
|
setProtocolSelectHost,
|
||||||
setQuickSearch,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||||
@@ -275,7 +275,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||||
@@ -504,6 +504,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
await popOutEditorTab(id, host?.label);
|
await popOutEditorTab(id, host?.label);
|
||||||
}, [hostById]);
|
}, [hostById]);
|
||||||
|
|
||||||
|
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
|
||||||
|
// skip re-rendering when unrelated AppView state changes.
|
||||||
|
const requestCloseEditorTabFromView = useCallback(
|
||||||
|
(id: string) => handleRequestCloseEditorTabRef.current(id),
|
||||||
|
[handleRequestCloseEditorTabRef],
|
||||||
|
);
|
||||||
|
const popOutEditorTabFromView = useCallback((id: string) => {
|
||||||
|
void handlePopOutEditorTab(id);
|
||||||
|
}, [handlePopOutEditorTab]);
|
||||||
|
|
||||||
const handleSaveSessionRename = useCallback((name: string) => {
|
const handleSaveSessionRename = useCallback((name: string) => {
|
||||||
if (!sessionRenameTarget) return;
|
if (!sessionRenameTarget) return;
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
@@ -581,7 +591,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onClosePluginViewTab={closePluginViewTab}
|
onClosePluginViewTab={closePluginViewTab}
|
||||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||||
onPopOutEditorTab={handlePopOutEditorTab}
|
onPopOutEditorTab={handlePopOutEditorTab}
|
||||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
onFocusEditorWindowTab={focusEditorWindowTab}
|
||||||
hostById={hostById}
|
hostById={hostById}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -845,8 +855,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
hotkeyScheme={hotkeyScheme}
|
hotkeyScheme={hotkeyScheme}
|
||||||
keyBindings={keyBindings}
|
keyBindings={keyBindings}
|
||||||
hostById={hostById}
|
hostById={hostById}
|
||||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
onRequestClose={requestCloseEditorTabFromView}
|
||||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
onPopOut={popOutEditorTabFromView}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
@@ -856,6 +866,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
/>
|
/>
|
||||||
|
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||||
|
<PluginAgentHost sessions={sessions} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||||
@@ -933,50 +945,39 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isQuickSwitcherOpen && (
|
{isQuickSwitcherOpen && (
|
||||||
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
|
<QuickSwitcherOverlay
|
||||||
<Suspense fallback={null}>
|
hosts={hosts}
|
||||||
<LazyQuickSwitcher
|
isOpen={isQuickSwitcherOpen}
|
||||||
isOpen={isQuickSwitcherOpen}
|
sessions={sessions}
|
||||||
query={quickSearch}
|
workspaces={workspaces}
|
||||||
results={quickResults}
|
showSftpTab={showSftpTab}
|
||||||
sessions={sessions}
|
onSelect={handleHostConnectWithProtocolCheck}
|
||||||
workspaces={workspaces}
|
onQuickConnect={openQuickConnectFromInput}
|
||||||
showSftpTab={showSftpTab}
|
onEditHost={(host) => {
|
||||||
onQueryChange={setQuickSearch}
|
setIsQuickSwitcherOpen(false);
|
||||||
onSelect={handleHostConnectWithProtocolCheck}
|
handleEditHostFromOverlay(host);
|
||||||
onQuickConnect={openQuickConnectFromInput}
|
}}
|
||||||
onEditHost={(host) => {
|
onSelectTab={(tabId) => {
|
||||||
setIsQuickSwitcherOpen(false);
|
setActiveTabId(tabId);
|
||||||
setQuickSearch('');
|
setIsQuickSwitcherOpen(false);
|
||||||
handleEditHostFromOverlay(host);
|
}}
|
||||||
}}
|
onCreateLocalTerminal={(shell) => {
|
||||||
onSelectTab={(tabId) => {
|
handleCreateLocalTerminal(shell);
|
||||||
setActiveTabId(tabId);
|
setIsQuickSwitcherOpen(false);
|
||||||
setIsQuickSwitcherOpen(false);
|
}}
|
||||||
setQuickSearch('');
|
onCreateWorkspace={() => {
|
||||||
}}
|
setIsQuickSwitcherOpen(false);
|
||||||
onCreateLocalTerminal={(shell) => {
|
setAddToWorkspaceDialog({ mode: 'create' });
|
||||||
handleCreateLocalTerminal(shell);
|
}}
|
||||||
setIsQuickSwitcherOpen(false);
|
onClose={() => {
|
||||||
setQuickSearch('');
|
setIsQuickSwitcherOpen(false);
|
||||||
}}
|
}}
|
||||||
onCreateWorkspace={() => {
|
keyBindings={keyBindings}
|
||||||
setIsQuickSwitcherOpen(false);
|
terminalSettings={terminalSettings}
|
||||||
setQuickSearch('');
|
paneMagnificationState={paneMagnificationState}
|
||||||
setAddToWorkspaceDialog({ mode: 'create' });
|
onMagnifyCurrentPane={handleMagnifyCurrentPane}
|
||||||
}}
|
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
|
||||||
onClose={() => {
|
/>
|
||||||
setIsQuickSwitcherOpen(false);
|
|
||||||
setQuickSearch('');
|
|
||||||
}}
|
|
||||||
keyBindings={keyBindings}
|
|
||||||
terminalSettings={terminalSettings}
|
|
||||||
paneMagnificationState={paneMagnificationState}
|
|
||||||
onMagnifyCurrentPane={handleMagnifyCurrentPane}
|
|
||||||
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</LazyLoadBoundary>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RenameDraftDialog
|
<RenameDraftDialog
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { Suspense, lazy, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
|
||||||
|
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||||
|
import { getHostSearchMatch } from '../../lib/searchMatcher';
|
||||||
|
import type { Host } from '../../types';
|
||||||
|
|
||||||
|
const LazyQuickSwitcher = lazy(() =>
|
||||||
|
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Hosts matching the quick switcher query, best match first. */
|
||||||
|
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
|
||||||
|
const term = query.trim();
|
||||||
|
if (!term) return hosts;
|
||||||
|
return hosts
|
||||||
|
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||||
|
.filter((entry) => entry.match.matched)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.match.score !== right.match.score) {
|
||||||
|
return right.match.score - left.match.score;
|
||||||
|
}
|
||||||
|
return left.host.label.localeCompare(right.host.label);
|
||||||
|
})
|
||||||
|
.map((entry) => entry.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuickSwitcherOverlayProps =
|
||||||
|
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the quick switcher query. It used to live in AppSideEffects and travel
|
||||||
|
* through the app-local UI store, so every keystroke re-rendered the whole
|
||||||
|
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
|
||||||
|
* The overlay is only mounted while the switcher is open, so closing it resets
|
||||||
|
* the query just like the old `setQuickSearch('')` calls did.
|
||||||
|
*/
|
||||||
|
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
|
||||||
|
return (
|
||||||
|
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
|
||||||
|
</Suspense>
|
||||||
|
</LazyLoadBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAppHandlers,
|
||||||
|
registerAppHandlers,
|
||||||
|
stabilizeAppHandlers,
|
||||||
|
subscribeAppHandlers,
|
||||||
|
type StableAppHandlerCache,
|
||||||
|
} from './appHandlersBridge';
|
||||||
|
|
||||||
|
test('stabilized handlers keep their identity and call the latest implementation', () => {
|
||||||
|
const cache: StableAppHandlerCache = new Map();
|
||||||
|
const ref = { current: 1 };
|
||||||
|
const calls: string[] = [];
|
||||||
|
const first = stabilizeAppHandlers(cache, {
|
||||||
|
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
|
||||||
|
toggleSidePanelRef: ref,
|
||||||
|
});
|
||||||
|
const second = stabilizeAppHandlers(cache, {
|
||||||
|
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
|
||||||
|
toggleSidePanelRef: ref,
|
||||||
|
});
|
||||||
|
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
|
||||||
|
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
|
||||||
|
(first.closeTabsBatch as (id: string) => void)('a');
|
||||||
|
assert.deepEqual(calls, ['v2:a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the bridge does not notify Hosts when only handler implementations change', () => {
|
||||||
|
const cache: StableAppHandlerCache = new Map();
|
||||||
|
let notified = 0;
|
||||||
|
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
|
||||||
|
try {
|
||||||
|
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
|
||||||
|
const afterFirst = notified;
|
||||||
|
// A session status change recreated the callback (it closes over hosts).
|
||||||
|
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
|
||||||
|
assert.equal(notified, afterFirst);
|
||||||
|
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
registerAppHandlers(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -51,6 +51,37 @@ class AppHandlersBridge {
|
|||||||
|
|
||||||
const bridge = new AppHandlersBridge();
|
const bridge = new AppHandlersBridge();
|
||||||
|
|
||||||
|
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every function-valued handler a per-key wrapper with a stable identity
|
||||||
|
* that always calls the latest implementation; non-functions (refs) pass
|
||||||
|
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
|
||||||
|
* identities changed on every session status change and host update (connect
|
||||||
|
* bursts, last-connected times), and the bridge re-notified every Host island,
|
||||||
|
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
|
||||||
|
* handlers are actions invoked from events, never read during render.
|
||||||
|
*/
|
||||||
|
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
|
||||||
|
const stableHandlers: AppHandlers = {};
|
||||||
|
for (const [key, value] of Object.entries(handlers)) {
|
||||||
|
if (typeof value !== 'function') {
|
||||||
|
stableHandlers[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
|
||||||
|
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
|
||||||
|
cache.set(key, created);
|
||||||
|
entry = created;
|
||||||
|
}
|
||||||
|
entry.latest = value;
|
||||||
|
stableHandlers[key] = entry.stable;
|
||||||
|
}
|
||||||
|
return stableHandlers;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
||||||
bridge.set(handlers);
|
bridge.set(handlers);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export type AppLocalUiSnapshot = {
|
|||||||
| { mode: 'append'; workspaceId: string }
|
| { mode: 'append'; workspaceId: string }
|
||||||
| { mode: 'create' }
|
| { mode: 'create' }
|
||||||
| null;
|
| null;
|
||||||
quickSearch: string;
|
|
||||||
protocolSelectHost: Host | null;
|
protocolSelectHost: Host | null;
|
||||||
navigateToSection: VaultSection | null;
|
navigateToSection: VaultSection | null;
|
||||||
deepLinkHostDraft: Host | null;
|
deepLinkHostDraft: Host | null;
|
||||||
@@ -43,7 +42,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
|||||||
isQuickSwitcherOpen: false,
|
isQuickSwitcherOpen: false,
|
||||||
isCreateWorkspaceOpen: false,
|
isCreateWorkspaceOpen: false,
|
||||||
addToWorkspaceDialog: null,
|
addToWorkspaceDialog: null,
|
||||||
quickSearch: '',
|
|
||||||
protocolSelectHost: null,
|
protocolSelectHost: null,
|
||||||
navigateToSection: null,
|
navigateToSection: null,
|
||||||
deepLinkHostDraft: null,
|
deepLinkHostDraft: null,
|
||||||
@@ -76,7 +74,6 @@ class AppLocalUiStore {
|
|||||||
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
||||||
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
||||||
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
||||||
&& prev.quickSearch === next.quickSearch
|
|
||||||
&& prev.protocolSelectHost === next.protocolSelectHost
|
&& prev.protocolSelectHost === next.protocolSelectHost
|
||||||
&& prev.navigateToSection === next.navigateToSection
|
&& prev.navigateToSection === next.navigateToSection
|
||||||
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getHostSearchMatch } from '../../../lib/searchMatcher';
|
|
||||||
import type { Host } from '../../../types';
|
|
||||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
|
||||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||||
import {
|
import {
|
||||||
publishAppShellDomainSlice,
|
publishAppShellDomainSlice,
|
||||||
@@ -11,16 +8,14 @@ import {
|
|||||||
} from '../appShellPropsStore';
|
} from '../appShellPropsStore';
|
||||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||||
|
|
||||||
const EMPTY_HOST_RESULTS: Host[] = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
* Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
|
||||||
* selective vault hosts subscription for quick-search results when open.
|
* switcher owns its query and host results (QuickSwitcherOverlay), so typing
|
||||||
|
* there no longer republishes this bag.
|
||||||
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
||||||
*/
|
*/
|
||||||
export function DialogsHost() {
|
export function DialogsHost() {
|
||||||
const local = useAppLocalUiStore();
|
const local = useAppLocalUiStore();
|
||||||
const vault = useVaultSnapshot();
|
|
||||||
const editorTabs = useEditorTabChromeList();
|
const editorTabs = useEditorTabChromeList();
|
||||||
const handlers = useSyncExternalStore(
|
const handlers = useSyncExternalStore(
|
||||||
subscribeAppHandlers,
|
subscribeAppHandlers,
|
||||||
@@ -28,22 +23,6 @@ export function DialogsHost() {
|
|||||||
getAppHandlers,
|
getAppHandlers,
|
||||||
);
|
);
|
||||||
|
|
||||||
const quickResults = useMemo(() => {
|
|
||||||
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
|
|
||||||
const term = local.quickSearch.trim();
|
|
||||||
if (!term) return vault.hosts as Host[];
|
|
||||||
return (vault.hosts as Host[])
|
|
||||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
|
||||||
.filter((entry) => entry.match.matched)
|
|
||||||
.sort((left, right) => {
|
|
||||||
if (left.match.score !== right.match.score) {
|
|
||||||
return right.match.score - left.match.score;
|
|
||||||
}
|
|
||||||
return left.host.label.localeCompare(right.host.label);
|
|
||||||
})
|
|
||||||
.map((entry) => entry.host);
|
|
||||||
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
|
|
||||||
|
|
||||||
const dialogsDomain = useMemo(() => {
|
const dialogsDomain = useMemo(() => {
|
||||||
if (!handlers) return null;
|
if (!handlers) return null;
|
||||||
return {
|
return {
|
||||||
@@ -68,14 +47,11 @@ export function DialogsHost() {
|
|||||||
passphraseQueue: local.passphraseQueue,
|
passphraseQueue: local.passphraseQueue,
|
||||||
fidoPromptQueue: local.fidoPromptQueue,
|
fidoPromptQueue: local.fidoPromptQueue,
|
||||||
protocolSelectHost: local.protocolSelectHost,
|
protocolSelectHost: local.protocolSelectHost,
|
||||||
quickResults,
|
|
||||||
quickSearch: local.quickSearch,
|
|
||||||
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
||||||
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
||||||
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
||||||
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
||||||
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
||||||
setQuickSearch: handlers.setQuickSearch,
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
editorTabs,
|
editorTabs,
|
||||||
@@ -88,8 +64,6 @@ export function DialogsHost() {
|
|||||||
local.passphraseQueue,
|
local.passphraseQueue,
|
||||||
local.fidoPromptQueue,
|
local.fidoPromptQueue,
|
||||||
local.protocolSelectHost,
|
local.protocolSelectHost,
|
||||||
local.quickSearch,
|
|
||||||
quickResults,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const overlays = useMemo(() => {
|
const overlays = useMemo(() => {
|
||||||
|
|||||||
@@ -14,7 +14,22 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
|
|||||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||||
});
|
});
|
||||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
// Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
|
||||||
|
return vm.runInNewContext(`${code}; ${name}`, {
|
||||||
|
React,
|
||||||
|
useState: React.useState,
|
||||||
|
useEffect: React.useEffect,
|
||||||
|
useLayoutEffect: React.useLayoutEffect,
|
||||||
|
useMemo: React.useMemo,
|
||||||
|
useCallback: React.useCallback,
|
||||||
|
useRef: React.useRef,
|
||||||
|
useReducer: React.useReducer,
|
||||||
|
useContext: React.useContext,
|
||||||
|
memo: React.memo,
|
||||||
|
createElement: React.createElement,
|
||||||
|
Fragment: React.Fragment,
|
||||||
|
...bindings,
|
||||||
|
}) as React.ComponentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||||
@@ -32,6 +47,7 @@ test('monitor controls update their runtime without rendering unrelated app sibl
|
|||||||
};
|
};
|
||||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||||
const App = loadComponent('../../App.tsx', 'App', {
|
const App = loadComponent('../../App.tsx', 'App', {
|
||||||
|
performance: { mark: () => {} },
|
||||||
useMonitorRuntime,
|
useMonitorRuntime,
|
||||||
MonitorRuntimeHost,
|
MonitorRuntimeHost,
|
||||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// Guards for what the startup bundle (index.html entry) may pull in. Each of
|
||||||
|
// these used to land in the entry chunk that every window parses at launch.
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
const read = (path: string) => readFileSync(join(root, path), 'utf8');
|
||||||
|
const staticImports = (source: string) =>
|
||||||
|
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
|
||||||
|
|
||||||
|
test('only the English dictionary is bundled with the startup entry', () => {
|
||||||
|
const imports = staticImports(read('application/i18n/messages.ts'));
|
||||||
|
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup modules do not statically import the AI runtime or SDK providers', () => {
|
||||||
|
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
|
||||||
|
for (const path of [
|
||||||
|
'application/state/aiStateSnapshots.ts',
|
||||||
|
'application/state/usePluginAIContextHost.ts',
|
||||||
|
'application/state/usePluginAgentHost.ts',
|
||||||
|
]) {
|
||||||
|
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
|
||||||
|
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
|
||||||
|
// The entry imports the helper for its dynamic imports; if it is grouped
|
||||||
|
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
|
||||||
|
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the QR decoder is loaded on demand', () => {
|
||||||
|
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
|
||||||
|
});
|
||||||
@@ -293,7 +293,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
terminalSettings,
|
terminalSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync tray menu data + handle tray actions
|
// Sync tray menu data + handle tray actions. Hosts are only re-sent when the
|
||||||
|
// host list changed: session status updates (connect bursts) used to resend
|
||||||
|
// and re-serialize every host and rebuild the macOS Dock menu each time.
|
||||||
|
const lastTrayHostsRef = useRef<unknown>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const bridge = netcattyBridge.get();
|
const bridge = netcattyBridge.get();
|
||||||
@@ -316,17 +319,21 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const hostsForSystemMenu = hosts
|
const hostsChanged = lastTrayHostsRef.current !== hosts;
|
||||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
lastTrayHostsRef.current = hosts;
|
||||||
.map((host: any) => ({
|
const hostsForSystemMenu = hostsChanged
|
||||||
id: host.id,
|
? hosts
|
||||||
label: host.label,
|
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||||
hostname: host.hostname,
|
.map((host: any) => ({
|
||||||
group: host.group,
|
id: host.id,
|
||||||
pinned: host.pinned,
|
label: host.label,
|
||||||
lastConnectedAt: host.lastConnectedAt,
|
hostname: host.hostname,
|
||||||
protocol: host.protocol,
|
group: host.group,
|
||||||
}));
|
pinned: host.pinned,
|
||||||
|
lastConnectedAt: host.lastConnectedAt,
|
||||||
|
protocol: host.protocol,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
void bridge.updateTrayMenuData({
|
void bridge.updateTrayMenuData({
|
||||||
sessions: sessionsForTray,
|
sessions: sessionsForTray,
|
||||||
@@ -334,7 +341,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
...rule,
|
...rule,
|
||||||
canStop: hasRuntimeTunnel(rule.id),
|
canStop: hasRuntimeTunnel(rule.id),
|
||||||
})),
|
})),
|
||||||
hosts: hostsForSystemMenu,
|
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
|
||||||
});
|
});
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,20 @@ Object.defineProperty(globalThis, 'localStorage', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
applyLegacySyncPayload,
|
||||||
|
convergentConflictAddressKey,
|
||||||
|
createConvergentSyncStateFromPayload,
|
||||||
|
dotKey,
|
||||||
|
mergeConvergentSyncStates,
|
||||||
planConvergentSyncMigration,
|
planConvergentSyncMigration,
|
||||||
|
refreshConvergentSyncMigrationPlan,
|
||||||
stripConvergentSyncEnvelope,
|
stripConvergentSyncEnvelope,
|
||||||
} = await import('../domain/convergentSync/index.ts');
|
} = await import('../domain/convergentSync/index.ts');
|
||||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||||
const {
|
const {
|
||||||
initializePreparedConvergentMigration,
|
initializePreparedConvergentMigration,
|
||||||
prepareConvergentSyncMigration,
|
prepareConvergentSyncMigration,
|
||||||
|
resolvePreparedConvergentMigrationConflict,
|
||||||
} = await import('./convergentSyncMigration.ts');
|
} = await import('./convergentSyncMigration.ts');
|
||||||
|
|
||||||
function payload(): SyncPayload {
|
function payload(): SyncPayload {
|
||||||
@@ -44,6 +51,42 @@ test.beforeEach(() => {
|
|||||||
localStorageValues.clear();
|
localStorageValues.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
|
||||||
|
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
|
||||||
|
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||||
|
const state = mergeConvergentSyncStates(
|
||||||
|
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
|
||||||
|
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
|
||||||
|
);
|
||||||
|
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
|
||||||
|
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
|
||||||
|
}), state);
|
||||||
|
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
|
||||||
|
const conflict = plan.preview.conflicts[0];
|
||||||
|
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||||
|
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
|
||||||
|
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
|
||||||
|
const resolved = resolvePreparedConvergentMigrationConflict(
|
||||||
|
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(resolved.plan.preview.canInitialize, true);
|
||||||
|
assert.equal(resolved.plan.preview.conflictCount, 0);
|
||||||
|
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
|
||||||
|
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
|
||||||
|
assert.ok(resolved.plan.state?.vector.resolver);
|
||||||
|
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
|
||||||
|
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
|
||||||
|
assert.equal(prepared.plan.preview.conflictCount, 1);
|
||||||
|
assert.equal(prepared.plan.payload, null);
|
||||||
|
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||||
|
prepared, 'missing', dotKey(selected.dot), manager,
|
||||||
|
), /conflict no longer exists/);
|
||||||
|
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||||
|
prepared, addressKey, 'missing', manager,
|
||||||
|
), /candidate no longer exists/);
|
||||||
|
});
|
||||||
|
|
||||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||||
const remotePayload: SyncPayload = {
|
const remotePayload: SyncPayload = {
|
||||||
...payload(),
|
...payload(),
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import type {
|
|||||||
} from '../domain/sync';
|
} from '../domain/sync';
|
||||||
import {
|
import {
|
||||||
cloudSyncPayloadsEqual,
|
cloudSyncPayloadsEqual,
|
||||||
|
convergentConflictAddressKey,
|
||||||
materializeSyncPayloadFromConvergentState,
|
materializeSyncPayloadFromConvergentState,
|
||||||
planConvergentSyncMigration,
|
planConvergentSyncMigration,
|
||||||
|
refreshConvergentSyncMigrationPlan,
|
||||||
|
resolveConvergentFieldConflict,
|
||||||
stripConvergentSyncEnvelope,
|
stripConvergentSyncEnvelope,
|
||||||
validateConvergentSyncPayload,
|
validateConvergentSyncPayload,
|
||||||
type ConvergentMigrationPlan,
|
type ConvergentMigrationPlan,
|
||||||
@@ -25,6 +28,25 @@ export interface PreparedConvergentMigration {
|
|||||||
localSnapshot: SyncPayload;
|
localSnapshot: SyncPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePreparedConvergentMigrationConflict(
|
||||||
|
prepared: PreparedConvergentMigration,
|
||||||
|
addressKey: string,
|
||||||
|
candidateDot: string,
|
||||||
|
manager: CloudSyncManager = getCloudSyncManager(),
|
||||||
|
now = Date.now(),
|
||||||
|
): PreparedConvergentMigration {
|
||||||
|
const conflict = prepared.plan.preview.conflicts.find(
|
||||||
|
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
|
||||||
|
);
|
||||||
|
if (!prepared.plan.state || !conflict) {
|
||||||
|
throw new Error('The convergent migration conflict no longer exists');
|
||||||
|
}
|
||||||
|
const state = resolveConvergentFieldConflict(
|
||||||
|
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
|
||||||
|
);
|
||||||
|
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
|
||||||
|
}
|
||||||
|
|
||||||
interface LegacyProviderBaselineSeed {
|
interface LegacyProviderBaselineSeed {
|
||||||
provider: CloudProvider;
|
provider: CloudProvider;
|
||||||
remoteVersion: number;
|
remoteVersion: number;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||||
import { MESSAGES_BY_LOCALE } from './messages';
|
import {
|
||||||
|
getBaseFallbackLocale,
|
||||||
|
getLoadedLocaleMessages,
|
||||||
|
getLocaleMessagesVersion,
|
||||||
|
loadLocaleMessages,
|
||||||
|
subscribeLocaleMessages,
|
||||||
|
type Messages,
|
||||||
|
} from './messages';
|
||||||
|
|
||||||
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
|
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
|
||||||
|
|
||||||
@@ -29,32 +36,59 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
|
|||||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
type LocaleDictionaries = {
|
||||||
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
|
direct?: Messages;
|
||||||
if (direct) return direct;
|
base?: Messages;
|
||||||
const base = resolvedLocale.split('-')[0];
|
fallback?: Messages;
|
||||||
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
|
|
||||||
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
|
|
||||||
if (baseHit) return baseHit;
|
|
||||||
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
|
||||||
|
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
|
||||||
|
* dictionary that finishes loading yields a fresh snapshot.
|
||||||
|
*/
|
||||||
|
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
|
||||||
|
const baseKey = getBaseFallbackLocale(resolvedLocale);
|
||||||
|
return {
|
||||||
|
direct: getLoadedLocaleMessages(resolvedLocale),
|
||||||
|
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
|
||||||
|
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
|
||||||
|
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
|
||||||
|
|
||||||
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
|
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
|
||||||
locale,
|
locale,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
|
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
|
||||||
|
// Non-English dictionaries load on demand (index.tsx preloads the saved one
|
||||||
|
// before the first render); re-render consumers once a switch finishes.
|
||||||
|
const messagesVersion = useSyncExternalStore(
|
||||||
|
subscribeLocaleMessages,
|
||||||
|
getLocaleMessagesVersion,
|
||||||
|
getLocaleMessagesVersion,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLocaleMessages(resolvedLocale).catch(() => {});
|
||||||
|
}, [resolvedLocale]);
|
||||||
|
const dictionaries = useMemo(
|
||||||
|
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
|
||||||
|
[resolvedLocale, messagesVersion],
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo<I18nContextValue>(() => {
|
const value = useMemo<I18nContextValue>(() => {
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
resolvedLocale,
|
resolvedLocale,
|
||||||
t: (key, values) => {
|
t: (key, values) => {
|
||||||
const msg = resolveMessage(resolvedLocale, key) ?? key;
|
const msg = resolveMessage(dictionaries, key) ?? key;
|
||||||
return interpolate(msg, values);
|
return interpolate(msg, values);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [locale, resolvedLocale]);
|
}, [dictionaries, locale, resolvedLocale]);
|
||||||
|
|
||||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||||
'ai.providers.advancedParams.default': 'Provider default',
|
'ai.providers.advancedParams.default': 'Provider default',
|
||||||
|
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
|
||||||
|
|
||||||
// AI Codex
|
// AI Codex
|
||||||
'ai.codex': 'Codex',
|
'ai.codex': 'Codex',
|
||||||
@@ -525,6 +526,8 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.chat.alwaysAllow': 'Always',
|
'ai.chat.alwaysAllow': 'Always',
|
||||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
'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.reject': 'Reject',
|
||||||
'ai.chat.toolLabel': 'Tool',
|
'ai.chat.toolLabel': 'Tool',
|
||||||
'ai.chat.targetLabel': 'Target',
|
'ai.chat.targetLabel': 'Target',
|
||||||
@@ -587,6 +590,28 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.chat.recent': 'Recent',
|
'ai.chat.recent': 'Recent',
|
||||||
'ai.chat.viewAll': 'View All',
|
'ai.chat.viewAll': 'View All',
|
||||||
'ai.chat.untitled': 'Untitled',
|
'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.justNow': 'Just now',
|
||||||
'ai.chat.minutesAgo': '{n}m ago',
|
'ai.chat.minutesAgo': '{n}m ago',
|
||||||
'ai.chat.hoursAgo': '{n}h ago',
|
'ai.chat.hoursAgo': '{n}h ago',
|
||||||
@@ -636,6 +661,10 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||||
|
'ai.chatHistory.title': 'Chat History',
|
||||||
|
'ai.chatHistory.messageCap': 'Messages kept per chat',
|
||||||
|
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
|
||||||
|
'ai.chatHistory.messageCap.unit': 'messages',
|
||||||
|
|
||||||
// AI Error
|
// AI Error
|
||||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||||
@@ -795,4 +824,27 @@ export const enAiMessages: Messages = {
|
|||||||
'zmodem.overwrite.skip': 'Skip',
|
'zmodem.overwrite.skip': 'Skip',
|
||||||
'zmodem.overwrite.cancel': 'Cancel',
|
'zmodem.overwrite.cancel': 'Cancel',
|
||||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
'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';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const enCoreMessages: Messages = {
|
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
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
@@ -135,7 +161,12 @@ export const enCoreMessages: Messages = {
|
|||||||
'settings.system.clearTempFiles': 'Clear temp files',
|
'settings.system.clearTempFiles': 'Clear temp files',
|
||||||
'settings.system.clearing': 'Clearing...',
|
'settings.system.clearing': 'Clearing...',
|
||||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
'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.title': 'Credential Protection',
|
||||||
'settings.system.credentials.status': 'Status',
|
'settings.system.credentials.status': 'Status',
|
||||||
'settings.system.credentials.checking': 'Checking...',
|
'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.uiFont.desc': 'Choose the font for the application interface',
|
||||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
'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.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': '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.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||||
'settings.appearance.appIcon.original': 'Original',
|
'settings.appearance.appIcon.original': 'Original',
|
||||||
@@ -1236,6 +1269,8 @@ Highlight the focused split pane:
|
|||||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||||
'settings.tab.plugins': 'Plugins',
|
'settings.tab.plugins': 'Plugins',
|
||||||
"settings.plugins.manager.installed": "Installed 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.import": "Import .ncpkg",
|
||||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
"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.empty": "Import a local plugin package to get started.",
|
||||||
@@ -1262,21 +1297,33 @@ Highlight the focused split pane:
|
|||||||
"plugins.interaction.share": "Share with plugin",
|
"plugins.interaction.share": "Share with plugin",
|
||||||
"plugins.interaction.copy": "Copy to clipboard",
|
"plugins.interaction.copy": "Copy to clipboard",
|
||||||
"plugins.interaction.failed": "This request expired or could not be completed.",
|
"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.current": "Matches the current activation",
|
||||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||||
"settings.plugins.permissions.title": "Saved grants",
|
"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.refresh": "Refresh",
|
||||||
"settings.plugins.permissions.loading": "Loading grants...",
|
"settings.plugins.permissions.loading": "Loading permissions...",
|
||||||
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
||||||
"settings.plugins.permissions.always": "Until revoked",
|
"settings.plugins.permissions.always": "Until revoked",
|
||||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||||
"settings.plugins.permissions.session": "Until the session closes",
|
"settings.plugins.permissions.session": "Until the session closes",
|
||||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
|
"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.permissions.unavailable": "Plugin permission management is unavailable",
|
||||||
"settings.plugins.manager.permissions": "Declared permissions",
|
"settings.plugins.manager.permissions": "Declared permissions",
|
||||||
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
|
"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.noPermissions": "No privileged permissions declared.",
|
||||||
"settings.plugins.manager.required": "Required",
|
"settings.plugins.manager.required": "Required",
|
||||||
"settings.plugins.manager.optional": "Optional",
|
"settings.plugins.manager.optional": "Optional",
|
||||||
@@ -1319,7 +1366,7 @@ Highlight the focused split pane:
|
|||||||
"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.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.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.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: review the data and resource scope before granting.",
|
"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.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.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.retry": "Retry",
|
||||||
@@ -1328,11 +1375,14 @@ Highlight the focused split pane:
|
|||||||
"settings.plugins.market.search": "Search name, author or description",
|
"settings.plugins.market.search": "Search name, author or description",
|
||||||
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
|
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
|
||||||
"settings.plugins.market.manifest": "Compatibility and permissions",
|
"settings.plugins.market.manifest": "Compatibility and permissions",
|
||||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
|
"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.openView": "Open",
|
||||||
"settings.plugins.openManager": "Open plugin manager",
|
"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.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.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.official": "Official marketplace",
|
||||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||||
"settings.plugins.sources.manage": "Manage sources",
|
"settings.plugins.sources.manage": "Manage sources",
|
||||||
@@ -1351,6 +1401,7 @@ Highlight the focused split pane:
|
|||||||
"settings.plugins.sources.local": "Local import",
|
"settings.plugins.sources.local": "Local import",
|
||||||
"settings.plugins.sources.all": "All sources",
|
"settings.plugins.sources.all": "All sources",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
|
"settings.plugins.configuration": "Plugin settings",
|
||||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||||
'settings.plugins.loading': 'Loading plugin settings…',
|
'settings.plugins.loading': 'Loading plugin settings…',
|
||||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||||
@@ -1380,5 +1431,20 @@ Highlight the focused split pane:
|
|||||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
'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.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||||
'plugins.authentication.continue': 'Continue',
|
'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 = {
|
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.diskIo": "Disk I/O",
|
||||||
"monitor.diskRead": "Read",
|
"monitor.diskRead": "Read",
|
||||||
"monitor.diskWrite": "Write",
|
"monitor.diskWrite": "Write",
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { Messages } from '../types';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const enTerminalMessages: Messages = {
|
export const enTerminalMessages: Messages = {
|
||||||
|
'terminal.directory.open': 'Current directory: {path}. Change directory',
|
||||||
|
'terminal.directory.unknown': 'Directory unknown',
|
||||||
|
'terminal.directory.search': 'Search child directories...',
|
||||||
|
'terminal.directory.directories': 'Directories',
|
||||||
|
'terminal.directory.parent': '.. (Parent directory)',
|
||||||
|
'terminal.directory.loading': 'Reading directories...',
|
||||||
|
'terminal.directory.empty': 'No child directories',
|
||||||
|
'terminal.directory.noMatches': 'No matching directories',
|
||||||
|
'terminal.directory.hidden': 'Show hidden directories',
|
||||||
|
'terminal.directory.refresh': 'Refresh',
|
||||||
|
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
|
||||||
|
'terminal.directory.busy': 'Wait for an empty shell prompt.',
|
||||||
|
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
|
||||||
|
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
|
||||||
|
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
|
||||||
|
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||||
|
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
|
||||||
|
|
||||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||||
@@ -460,6 +478,7 @@ export const enTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||||
'cloudSync.localBackups.title': 'Local Backup History',
|
'cloudSync.localBackups.title': 'Local Backup History',
|
||||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||||
|
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
|
||||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||||
@@ -474,6 +493,7 @@ export const enTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||||
|
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
|
||||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||||
|
|||||||
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
|
|||||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
'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': 'Backspace Behavior',
|
||||||
'hostDetails.backspaceBehavior.default': 'Default',
|
'hostDetails.backspaceBehavior.default': 'Default',
|
||||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const esAiMessages: Messages = {
|
|||||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
'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.maxTokens.placeholder': 'p. ej. 4096',
|
||||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||||
|
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
|
||||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
'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.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.name': 'Nombre de la cabecera',
|
||||||
@@ -525,6 +526,8 @@ export const esAiMessages: Messages = {
|
|||||||
'ai.chat.alwaysAllow': 'Siempre',
|
'ai.chat.alwaysAllow': 'Siempre',
|
||||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
'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.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.reject': 'Rechazar',
|
||||||
'ai.chat.toolLabel': 'Herramienta',
|
'ai.chat.toolLabel': 'Herramienta',
|
||||||
'ai.chat.targetLabel': 'Destino',
|
'ai.chat.targetLabel': 'Destino',
|
||||||
@@ -587,6 +590,28 @@ export const esAiMessages: Messages = {
|
|||||||
'ai.chat.recent': 'Recientes',
|
'ai.chat.recent': 'Recientes',
|
||||||
'ai.chat.viewAll': 'Ver todo',
|
'ai.chat.viewAll': 'Ver todo',
|
||||||
'ai.chat.untitled': 'Sin título',
|
'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.justNow': 'Ahora mismo',
|
||||||
'ai.chat.minutesAgo': 'hace {n} min',
|
'ai.chat.minutesAgo': 'hace {n} min',
|
||||||
'ai.chat.hoursAgo': 'hace {n} h',
|
'ai.chat.hoursAgo': 'hace {n} h',
|
||||||
@@ -636,6 +661,10 @@ export const esAiMessages: Messages = {
|
|||||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||||
|
'ai.chatHistory.title': 'Historial de chat',
|
||||||
|
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
|
||||||
|
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
|
||||||
|
'ai.chatHistory.messageCap.unit': 'mensajes',
|
||||||
|
|
||||||
// AI Error
|
// AI Error
|
||||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||||
@@ -795,4 +824,27 @@ export const esAiMessages: Messages = {
|
|||||||
'zmodem.overwrite.skip': 'Omitir',
|
'zmodem.overwrite.skip': 'Omitir',
|
||||||
'zmodem.overwrite.cancel': 'Cancelar',
|
'zmodem.overwrite.cancel': 'Cancelar',
|
||||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
'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';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const esCoreMessages: Messages = {
|
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
|
||||||
'common.save': 'Guardar',
|
'common.save': 'Guardar',
|
||||||
'common.cancel': 'Cancelar',
|
'common.cancel': 'Cancelar',
|
||||||
@@ -135,7 +161,12 @@ export const esCoreMessages: Messages = {
|
|||||||
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
|
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
|
||||||
'settings.system.clearing': 'Limpiando...',
|
'settings.system.clearing': 'Limpiando...',
|
||||||
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
|
'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.title': 'Protección de credenciales',
|
||||||
'settings.system.credentials.status': 'Estado',
|
'settings.system.credentials.status': 'Estado',
|
||||||
'settings.system.credentials.checking': 'Comprobando...',
|
'settings.system.credentials.checking': 'Comprobando...',
|
||||||
@@ -556,6 +587,8 @@ Resalta el panel dividido enfocado:
|
|||||||
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
|
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
|
||||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
'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.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': 'Í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.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||||
'settings.appearance.appIcon.original': 'Original',
|
'settings.appearance.appIcon.original': 'Original',
|
||||||
@@ -1235,6 +1268,8 @@ Resalta el panel dividido enfocado:
|
|||||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||||
'settings.tab.plugins': 'Plugins',
|
'settings.tab.plugins': 'Plugins',
|
||||||
|
"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.installed": "Plugins instalados",
|
||||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||||
@@ -1262,9 +1297,21 @@ Resalta el panel dividido enfocado:
|
|||||||
"plugins.interaction.share": "Compartir con el complemento",
|
"plugins.interaction.share": "Compartir con el complemento",
|
||||||
"plugins.interaction.copy": "Copiar al portapapeles",
|
"plugins.interaction.copy": "Copiar al portapapeles",
|
||||||
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
|
"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.current": "Coincide con la ejecución actual",
|
||||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||||
"settings.plugins.permissions.title": "Autorizaciones guardadas",
|
"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.refresh": "Actualizar",
|
||||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
"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.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
|
||||||
@@ -1276,7 +1323,7 @@ Resalta el panel dividido enfocado:
|
|||||||
"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.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.permissions.unavailable": "La gestión de permisos no está disponible",
|
||||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||||
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
|
"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.noPermissions": "No se han declarado permisos privilegiados.",
|
||||||
"settings.plugins.manager.required": "Obligatorio",
|
"settings.plugins.manager.required": "Obligatorio",
|
||||||
"settings.plugins.manager.optional": "Opcional",
|
"settings.plugins.manager.optional": "Opcional",
|
||||||
@@ -1319,7 +1366,7 @@ Resalta el panel dividido enfocado:
|
|||||||
"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.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.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.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: revisa los datos y el alcance antes de autorizar.",
|
"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.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.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.retry": "Reintentar",
|
||||||
@@ -1328,11 +1375,14 @@ Resalta el panel dividido enfocado:
|
|||||||
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
|
"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.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
|
||||||
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
||||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
|
"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.openView": "Abrir",
|
||||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
"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.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.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.official": "Official marketplace",
|
||||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||||
"settings.plugins.sources.manage": "Manage sources",
|
"settings.plugins.sources.manage": "Manage sources",
|
||||||
@@ -1351,6 +1401,7 @@ Resalta el panel dividido enfocado:
|
|||||||
"settings.plugins.sources.local": "Local import",
|
"settings.plugins.sources.local": "Local import",
|
||||||
"settings.plugins.sources.all": "All sources",
|
"settings.plugins.sources.all": "All sources",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
|
"settings.plugins.configuration": "Ajustes del plugin",
|
||||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||||
@@ -1380,5 +1431,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.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.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||||
'plugins.authentication.continue': 'Continuar',
|
'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 = {
|
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.diskIo": "E/S de disco",
|
||||||
"monitor.diskRead": "Lectura",
|
"monitor.diskRead": "Lectura",
|
||||||
"monitor.diskWrite": "Escritura",
|
"monitor.diskWrite": "Escritura",
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { Messages } from '../types';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const esTerminalMessages: Messages = {
|
export const esTerminalMessages: Messages = {
|
||||||
|
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
|
||||||
|
'terminal.directory.unknown': 'Directorio desconocido',
|
||||||
|
'terminal.directory.search': 'Buscar subdirectorios...',
|
||||||
|
'terminal.directory.directories': 'Directorios',
|
||||||
|
'terminal.directory.parent': '.. (Directorio padre)',
|
||||||
|
'terminal.directory.loading': 'Leyendo directorios...',
|
||||||
|
'terminal.directory.empty': 'Sin subdirectorios',
|
||||||
|
'terminal.directory.noMatches': 'Sin directorios coincidentes',
|
||||||
|
'terminal.directory.hidden': 'Mostrar directorios ocultos',
|
||||||
|
'terminal.directory.refresh': 'Actualizar',
|
||||||
|
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
|
||||||
|
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
|
||||||
|
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
|
||||||
|
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
|
||||||
|
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
|
||||||
|
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||||
|
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
|
||||||
|
|
||||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||||
@@ -460,6 +478,7 @@ export const esTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||||
|
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
|
||||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||||
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
|
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
|
||||||
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
||||||
@@ -474,6 +493,7 @@ export const esTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
||||||
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
||||||
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
||||||
|
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
|
||||||
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
||||||
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
||||||
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
||||||
|
|||||||
@@ -965,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.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': '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.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': 'Comportamiento de la tecla Retroceso',
|
||||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBaseFallbackLocale,
|
||||||
|
getLoadedLocaleMessages,
|
||||||
|
getLocaleMessagesVersion,
|
||||||
|
loadLocaleMessages,
|
||||||
|
subscribeLocaleMessages,
|
||||||
|
} from '../messages';
|
||||||
|
|
||||||
|
test('only English is loaded until another locale is requested', () => {
|
||||||
|
assert.ok(getLoadedLocaleMessages('en'));
|
||||||
|
assert.equal(getLoadedLocaleMessages('ru'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
|
||||||
|
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
|
||||||
|
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
|
||||||
|
assert.equal(getBaseFallbackLocale('es'), 'es');
|
||||||
|
assert.equal(getBaseFallbackLocale('fr'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
|
||||||
|
const before = getLocaleMessagesVersion();
|
||||||
|
let notified = 0;
|
||||||
|
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
|
||||||
|
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
|
||||||
|
unsubscribe();
|
||||||
|
assert.ok(getLoadedLocaleMessages('zh-TW'));
|
||||||
|
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
|
||||||
|
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
|
||||||
|
assert.equal(getLocaleMessagesVersion(), before + 2);
|
||||||
|
await loadLocaleMessages('zh-TW');
|
||||||
|
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
|
||||||
|
});
|
||||||
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||||
|
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
|
||||||
|
|
||||||
// AI Codex
|
// AI Codex
|
||||||
'ai.codex': 'Codex',
|
'ai.codex': 'Codex',
|
||||||
@@ -464,6 +465,8 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||||
|
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
|
||||||
|
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
|
||||||
'ai.chat.reject': 'Отклонить',
|
'ai.chat.reject': 'Отклонить',
|
||||||
'ai.chat.toolLabel': 'Инструмент',
|
'ai.chat.toolLabel': 'Инструмент',
|
||||||
'ai.chat.targetLabel': 'Цель',
|
'ai.chat.targetLabel': 'Цель',
|
||||||
@@ -526,6 +529,28 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.chat.recent': 'Недавние',
|
'ai.chat.recent': 'Недавние',
|
||||||
'ai.chat.viewAll': 'Показать всё',
|
'ai.chat.viewAll': 'Показать всё',
|
||||||
'ai.chat.untitled': 'Без названия',
|
'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.justNow': 'Только что',
|
||||||
'ai.chat.minutesAgo': '{n}м назад',
|
'ai.chat.minutesAgo': '{n}м назад',
|
||||||
'ai.chat.hoursAgo': '{n}ч назад',
|
'ai.chat.hoursAgo': '{n}ч назад',
|
||||||
@@ -575,6 +600,10 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||||
|
'ai.chatHistory.title': 'История чата',
|
||||||
|
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
|
||||||
|
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
|
||||||
|
'ai.chatHistory.messageCap.unit': 'сообщений',
|
||||||
|
|
||||||
// AI Error
|
// AI Error
|
||||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||||
@@ -724,4 +753,27 @@ export const ruAiMessages: Messages = {
|
|||||||
'zmodem.overwrite.skip': 'Пропустить',
|
'zmodem.overwrite.skip': 'Пропустить',
|
||||||
'zmodem.overwrite.cancel': 'Отмена',
|
'zmodem.overwrite.cancel': 'Отмена',
|
||||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
'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.clearTempFiles': 'Очистить временные файлы',
|
||||||
'settings.system.clearing': 'Очистка...',
|
'settings.system.clearing': 'Очистка...',
|
||||||
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
|
'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.title': 'Защита учётных данных',
|
||||||
'settings.system.credentials.status': 'Статус',
|
'settings.system.credentials.status': 'Статус',
|
||||||
'settings.system.credentials.checking': 'Проверка...',
|
'settings.system.credentials.checking': 'Проверка...',
|
||||||
@@ -525,6 +530,8 @@ export const ruCoreMessages: Messages = {
|
|||||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||||
|
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
|
||||||
|
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
|
||||||
'settings.appearance.appIcon': 'Значок приложения',
|
'settings.appearance.appIcon': 'Значок приложения',
|
||||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||||
'settings.appearance.appIcon.original': 'Оригинал',
|
'settings.appearance.appIcon.original': 'Оригинал',
|
||||||
@@ -1130,6 +1137,8 @@ export const ruCoreMessages: Messages = {
|
|||||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||||
|
|
||||||
'settings.tab.plugins': 'Плагины',
|
'settings.tab.plugins': 'Плагины',
|
||||||
|
"settings.plugins.author.official": "Официальная поддержка",
|
||||||
|
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
|
||||||
"settings.plugins.manager.installed": "Установленные плагины",
|
"settings.plugins.manager.installed": "Установленные плагины",
|
||||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||||
@@ -1157,6 +1166,14 @@ export const ruCoreMessages: Messages = {
|
|||||||
"plugins.interaction.share": "Передать плагину",
|
"plugins.interaction.share": "Передать плагину",
|
||||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
"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.current": "Соответствует текущему запуску",
|
||||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||||
@@ -1171,7 +1188,7 @@ export const ruCoreMessages: Messages = {
|
|||||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
|
||||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||||
"settings.plugins.manager.required": "Обязательно",
|
"settings.plugins.manager.required": "Обязательно",
|
||||||
"settings.plugins.manager.optional": "Необязательно",
|
"settings.plugins.manager.optional": "Необязательно",
|
||||||
@@ -1214,7 +1231,7 @@ export const ruCoreMessages: Messages = {
|
|||||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
|
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
|
||||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||||
"settings.plugins.market.retry": "Повторить",
|
"settings.plugins.market.retry": "Повторить",
|
||||||
@@ -1223,11 +1240,14 @@ export const ruCoreMessages: Messages = {
|
|||||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
|
||||||
"settings.plugins.openView": "Открыть",
|
"settings.plugins.openView": "Открыть",
|
||||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
"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.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.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.official": "Official marketplace",
|
||||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||||
"settings.plugins.sources.manage": "Manage sources",
|
"settings.plugins.sources.manage": "Manage sources",
|
||||||
@@ -1246,6 +1266,7 @@ export const ruCoreMessages: Messages = {
|
|||||||
"settings.plugins.sources.local": "Local import",
|
"settings.plugins.sources.local": "Local import",
|
||||||
"settings.plugins.sources.all": "All sources",
|
"settings.plugins.sources.all": "All sources",
|
||||||
"settings.plugins.title": "Плагины",
|
"settings.plugins.title": "Плагины",
|
||||||
|
"settings.plugins.configuration": "Настройки плагина",
|
||||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||||
@@ -1275,4 +1296,19 @@ export const ruCoreMessages: Messages = {
|
|||||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||||
'plugins.authentication.continue': 'Продолжить',
|
'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 = {
|
export const ruMonitorMessages = {
|
||||||
|
"monitor.systemDisks": "Системные разделы ({count})",
|
||||||
|
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||||
|
"monitor.diskIoTotal": "Всего",
|
||||||
|
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||||
"monitor.diskRead": "Чтение",
|
"monitor.diskRead": "Чтение",
|
||||||
"monitor.diskWrite": "Запись",
|
"monitor.diskWrite": "Запись",
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { Messages } from '../types';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const ruTerminalMessages: Messages = {
|
export const ruTerminalMessages: Messages = {
|
||||||
|
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
|
||||||
|
'terminal.directory.unknown': 'Каталог неизвестен',
|
||||||
|
'terminal.directory.search': 'Поиск подкаталогов...',
|
||||||
|
'terminal.directory.directories': 'Каталоги',
|
||||||
|
'terminal.directory.parent': '.. (Родительский каталог)',
|
||||||
|
'terminal.directory.loading': 'Чтение каталогов...',
|
||||||
|
'terminal.directory.empty': 'Нет подкаталогов',
|
||||||
|
'terminal.directory.noMatches': 'Нет подходящих каталогов',
|
||||||
|
'terminal.directory.hidden': 'Показывать скрытые каталоги',
|
||||||
|
'terminal.directory.refresh': 'Обновить',
|
||||||
|
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
|
||||||
|
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
|
||||||
|
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
|
||||||
|
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
|
||||||
|
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
|
||||||
|
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||||
|
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
|
||||||
|
|
||||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||||
@@ -478,6 +496,7 @@ export const ruTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||||
|
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
|
||||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||||
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
|
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
|
||||||
'cloudSync.localBackups.maxCount': 'Макс. число копий',
|
'cloudSync.localBackups.maxCount': 'Макс. число копий',
|
||||||
@@ -492,6 +511,7 @@ export const ruTerminalMessages: Messages = {
|
|||||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||||
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
|
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
|
||||||
|
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
|
||||||
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
|
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
|
||||||
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
|
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
|
||||||
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
||||||
|
|||||||
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
|
|||||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||||
|
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
|
||||||
|
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
|
||||||
|
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
|
||||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ const ALL_SETTINGS_LOCALES = [
|
|||||||
...LOCALIZED_SETTINGS_LOCALES,
|
...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", () => {
|
test("localized settings include names for every default shortcut", () => {
|
||||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||||
const missing = DEFAULT_KEY_BINDINGS
|
const missing = DEFAULT_KEY_BINDINGS
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||||
'ai.providers.advancedParams.default': '提供商默认',
|
'ai.providers.advancedParams.default': '提供商默认',
|
||||||
|
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
|
||||||
|
|
||||||
// AI Codex
|
// AI Codex
|
||||||
'ai.codex': 'Codex',
|
'ai.codex': 'Codex',
|
||||||
@@ -525,6 +526,8 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.chat.alwaysAllow': '始终',
|
'ai.chat.alwaysAllow': '始终',
|
||||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||||
|
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
|
||||||
|
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
|
||||||
'ai.chat.reject': '拒绝',
|
'ai.chat.reject': '拒绝',
|
||||||
'ai.chat.toolLabel': '工具',
|
'ai.chat.toolLabel': '工具',
|
||||||
'ai.chat.targetLabel': '目标',
|
'ai.chat.targetLabel': '目标',
|
||||||
@@ -587,6 +590,28 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.chat.recent': '最近',
|
'ai.chat.recent': '最近',
|
||||||
'ai.chat.viewAll': '查看全部',
|
'ai.chat.viewAll': '查看全部',
|
||||||
'ai.chat.untitled': '无标题',
|
'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.justNow': '刚刚',
|
||||||
'ai.chat.minutesAgo': '{n}分钟前',
|
'ai.chat.minutesAgo': '{n}分钟前',
|
||||||
'ai.chat.hoursAgo': '{n}小时前',
|
'ai.chat.hoursAgo': '{n}小时前',
|
||||||
@@ -636,6 +661,10 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||||
|
'ai.chatHistory.title': '聊天历史',
|
||||||
|
'ai.chatHistory.messageCap': '每个会话保留的消息数',
|
||||||
|
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
|
||||||
|
'ai.chatHistory.messageCap.unit': '条消息',
|
||||||
|
|
||||||
// AI Error
|
// AI Error
|
||||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||||
@@ -795,4 +824,27 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'zmodem.overwrite.skip': '跳过',
|
'zmodem.overwrite.skip': '跳过',
|
||||||
'zmodem.overwrite.cancel': '取消',
|
'zmodem.overwrite.cancel': '取消',
|
||||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
'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';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const zhCNCoreMessages: Messages = {
|
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
|
||||||
'common.save': '保存',
|
'common.save': '保存',
|
||||||
'common.cancel': '取消',
|
'common.cancel': '取消',
|
||||||
@@ -139,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'settings.system.clearTempFiles': '清理临时文件',
|
'settings.system.clearTempFiles': '清理临时文件',
|
||||||
'settings.system.clearing': '清理中...',
|
'settings.system.clearing': '清理中...',
|
||||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
'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.title': '凭据保护',
|
||||||
'settings.system.credentials.status': '状态',
|
'settings.system.credentials.status': '状态',
|
||||||
'settings.system.credentials.checking': '检查中...',
|
'settings.system.credentials.checking': '检查中...',
|
||||||
@@ -560,6 +591,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||||
'settings.appearance.windowOpacity': '窗口透明度',
|
'settings.appearance.windowOpacity': '窗口透明度',
|
||||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||||
|
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||||
|
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
|
||||||
'settings.appearance.appIcon': '应用图标',
|
'settings.appearance.appIcon': '应用图标',
|
||||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||||
'settings.appearance.appIcon.original': '原始',
|
'settings.appearance.appIcon.original': '原始',
|
||||||
@@ -1151,6 +1184,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
'qs.default': '默认',
|
'qs.default': '默认',
|
||||||
'settings.tab.plugins': '插件',
|
'settings.tab.plugins': '插件',
|
||||||
"settings.plugins.manager.installed": "已安装插件",
|
"settings.plugins.manager.installed": "已安装插件",
|
||||||
|
"settings.plugins.author.official": "官方维护",
|
||||||
|
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
|
||||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||||
@@ -1177,21 +1212,33 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
"plugins.interaction.share": "分享给插件",
|
"plugins.interaction.share": "分享给插件",
|
||||||
"plugins.interaction.copy": "复制到剪贴板",
|
"plugins.interaction.copy": "复制到剪贴板",
|
||||||
"plugins.interaction.failed": "此请求已过期或无法完成。",
|
"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.current": "匹配当前运行实例",
|
||||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||||
"settings.plugins.permissions.title": "已保存的授权",
|
"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.refresh": "刷新",
|
||||||
"settings.plugins.permissions.loading": "正在读取授权...",
|
"settings.plugins.permissions.loading": "正在加载权限...",
|
||||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||||
"settings.plugins.permissions.always": "直到撤销",
|
"settings.plugins.permissions.always": "直到撤销",
|
||||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||||
"settings.plugins.permissions.session": "直到会话关闭",
|
"settings.plugins.permissions.session": "直到会话关闭",
|
||||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
|
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
|
||||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||||
"settings.plugins.manager.permissions": "声明的权限",
|
"settings.plugins.manager.permissions": "声明的权限",
|
||||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
|
||||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||||
"settings.plugins.manager.required": "必需",
|
"settings.plugins.manager.required": "必需",
|
||||||
"settings.plugins.manager.optional": "可选",
|
"settings.plugins.manager.optional": "可选",
|
||||||
@@ -1234,7 +1281,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||||
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
|
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
|
||||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||||
"settings.plugins.market.retry": "重试",
|
"settings.plugins.market.retry": "重试",
|
||||||
@@ -1243,11 +1290,14 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||||
"settings.plugins.market.manifest": "兼容性与权限",
|
"settings.plugins.market.manifest": "兼容性与权限",
|
||||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
|
||||||
"settings.plugins.openView": "打开",
|
"settings.plugins.openView": "打开",
|
||||||
"settings.plugins.openManager": "打开插件管理",
|
"settings.plugins.openManager": "打开插件管理",
|
||||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
"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.official": "官方市场",
|
||||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||||
"settings.plugins.sources.manage": "管理来源",
|
"settings.plugins.sources.manage": "管理来源",
|
||||||
@@ -1266,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
"settings.plugins.sources.local": "本地导入",
|
"settings.plugins.sources.local": "本地导入",
|
||||||
"settings.plugins.sources.all": "全部来源",
|
"settings.plugins.sources.all": "全部来源",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
|
"settings.plugins.configuration": "插件设置",
|
||||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||||
'settings.plugins.loading': '正在加载插件设置…',
|
'settings.plugins.loading': '正在加载插件设置…',
|
||||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||||
@@ -1295,5 +1346,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||||
'plugins.authentication.continue': '继续',
|
'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 = {
|
export const zhCNMonitorMessages = {
|
||||||
|
"monitor.systemDisks": "系统分区({count})",
|
||||||
|
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
|
||||||
|
"monitor.diskIoTotal": "汇总",
|
||||||
|
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
|
||||||
"monitor.diskIo": "磁盘 I/O",
|
"monitor.diskIo": "磁盘 I/O",
|
||||||
"monitor.diskRead": "读取",
|
"monitor.diskRead": "读取",
|
||||||
"monitor.diskWrite": "写入",
|
"monitor.diskWrite": "写入",
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { Messages } from '../types';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const zhCNTerminalMessages: Messages = {
|
export const zhCNTerminalMessages: Messages = {
|
||||||
|
'terminal.directory.open': '当前目录:{path}。更改目录',
|
||||||
|
'terminal.directory.unknown': '目录未知',
|
||||||
|
'terminal.directory.search': '搜索当前目录下的文件夹…',
|
||||||
|
'terminal.directory.directories': '目录',
|
||||||
|
'terminal.directory.parent': '..(父目录)',
|
||||||
|
'terminal.directory.loading': '正在读取目录…',
|
||||||
|
'terminal.directory.empty': '没有子目录',
|
||||||
|
'terminal.directory.noMatches': '没有匹配的子目录',
|
||||||
|
'terminal.directory.hidden': '显示隐藏目录',
|
||||||
|
'terminal.directory.refresh': '刷新',
|
||||||
|
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
|
||||||
|
'terminal.directory.busy': '请等待终端回到空命令提示符。',
|
||||||
|
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
|
||||||
|
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
|
||||||
|
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
|
||||||
|
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
|
||||||
|
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
|
||||||
|
|
||||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||||
|
|||||||
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||||
|
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
|
||||||
|
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
|
||||||
|
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
|
||||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||||
'hostDetails.backspaceBehavior.default': '默认',
|
'hostDetails.backspaceBehavior.default': '默认',
|
||||||
'hostDetails.jumpHosts': '通过主机代理',
|
'hostDetails.jumpHosts': '通过主机代理',
|
||||||
@@ -885,6 +888,7 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||||
'cloudSync.localBackups.title': '本地备份历史',
|
'cloudSync.localBackups.title': '本地备份历史',
|
||||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||||
|
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
|
||||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||||
'cloudSync.localBackups.maxCount': '最多保留',
|
'cloudSync.localBackups.maxCount': '最多保留',
|
||||||
@@ -899,6 +903,7 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||||
|
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
|
||||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
||||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||||
'ai.providers.advancedParams.default': '供應商預設',
|
'ai.providers.advancedParams.default': '供應商預設',
|
||||||
|
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
|
||||||
|
|
||||||
// AI Codex
|
// AI Codex
|
||||||
'ai.codex': 'Codex',
|
'ai.codex': 'Codex',
|
||||||
@@ -515,6 +516,8 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.chat.alwaysAllow': '始終',
|
'ai.chat.alwaysAllow': '始終',
|
||||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||||
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
||||||
|
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
|
||||||
|
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
|
||||||
'ai.chat.reject': '拒絕',
|
'ai.chat.reject': '拒絕',
|
||||||
'ai.chat.toolLabel': '工具',
|
'ai.chat.toolLabel': '工具',
|
||||||
'ai.chat.targetLabel': '目標',
|
'ai.chat.targetLabel': '目標',
|
||||||
@@ -577,6 +580,28 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.chat.recent': '最近',
|
'ai.chat.recent': '最近',
|
||||||
'ai.chat.viewAll': '檢視全部',
|
'ai.chat.viewAll': '檢視全部',
|
||||||
'ai.chat.untitled': '無標題',
|
'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.justNow': '剛剛',
|
||||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||||
'ai.chat.hoursAgo': '{n}小時前',
|
'ai.chat.hoursAgo': '{n}小時前',
|
||||||
@@ -626,6 +651,10 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||||
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
||||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||||
|
'ai.chatHistory.title': '聊天歷史',
|
||||||
|
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
|
||||||
|
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
|
||||||
|
'ai.chatHistory.messageCap.unit': '則訊息',
|
||||||
|
|
||||||
// AI Error
|
// AI Error
|
||||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
||||||
@@ -785,4 +814,27 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'zmodem.overwrite.skip': '跳過',
|
'zmodem.overwrite.skip': '跳過',
|
||||||
'zmodem.overwrite.cancel': '取消',
|
'zmodem.overwrite.cancel': '取消',
|
||||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
'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';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const zhTWCoreMessages: Messages = {
|
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
|
||||||
'common.save': '儲存',
|
'common.save': '儲存',
|
||||||
'common.cancel': '取消',
|
'common.cancel': '取消',
|
||||||
@@ -141,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'settings.system.clearTempFiles': '清理臨時檔案',
|
'settings.system.clearTempFiles': '清理臨時檔案',
|
||||||
'settings.system.clearing': '清理中...',
|
'settings.system.clearing': '清理中...',
|
||||||
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
|
'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.title': '憑證保護',
|
||||||
'settings.system.credentials.status': '狀態',
|
'settings.system.credentials.status': '狀態',
|
||||||
'settings.system.credentials.checking': '檢查中...',
|
'settings.system.credentials.checking': '檢查中...',
|
||||||
@@ -562,6 +593,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
|
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
|
||||||
'settings.appearance.windowOpacity': '視窗透明度',
|
'settings.appearance.windowOpacity': '視窗透明度',
|
||||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||||
|
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||||
|
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
|
||||||
'settings.appearance.appIcon': '應用程式圖示',
|
'settings.appearance.appIcon': '應用程式圖示',
|
||||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||||
'settings.appearance.appIcon.original': '原始',
|
'settings.appearance.appIcon.original': '原始',
|
||||||
@@ -1153,6 +1186,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
'qs.default': '預設',
|
'qs.default': '預設',
|
||||||
|
|
||||||
'settings.tab.plugins': '外掛程式',
|
'settings.tab.plugins': '外掛程式',
|
||||||
|
"settings.plugins.author.official": "官方維護",
|
||||||
|
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
|
||||||
"settings.plugins.manager.installed": "已安裝插件",
|
"settings.plugins.manager.installed": "已安裝插件",
|
||||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||||
@@ -1180,21 +1215,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
"plugins.interaction.share": "分享給外掛",
|
"plugins.interaction.share": "分享給外掛",
|
||||||
"plugins.interaction.copy": "複製到剪貼簿",
|
"plugins.interaction.copy": "複製到剪貼簿",
|
||||||
"plugins.interaction.failed": "此請求已過期或無法完成。",
|
"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.current": "符合目前執行個體",
|
||||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||||
"settings.plugins.permissions.title": "已儲存的授權",
|
"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.refresh": "重新整理",
|
||||||
"settings.plugins.permissions.loading": "正在讀取授權...",
|
"settings.plugins.permissions.loading": "正在載入權限...",
|
||||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||||
"settings.plugins.permissions.always": "直到撤銷",
|
"settings.plugins.permissions.always": "直到撤銷",
|
||||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
|
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
|
||||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||||
"settings.plugins.manager.permissions": "聲明的權限",
|
"settings.plugins.manager.permissions": "聲明的權限",
|
||||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
|
||||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||||
"settings.plugins.manager.required": "必需",
|
"settings.plugins.manager.required": "必需",
|
||||||
"settings.plugins.manager.optional": "可選",
|
"settings.plugins.manager.optional": "可選",
|
||||||
@@ -1214,11 +1261,14 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
|
||||||
"settings.plugins.openView": "開啟",
|
"settings.plugins.openView": "開啟",
|
||||||
"settings.plugins.openManager": "開啟插件管理",
|
"settings.plugins.openManager": "開啟插件管理",
|
||||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
"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.official": "官方市场",
|
||||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||||
"settings.plugins.sources.manage": "管理来源",
|
"settings.plugins.sources.manage": "管理来源",
|
||||||
@@ -1237,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
"settings.plugins.sources.local": "本地导入",
|
"settings.plugins.sources.local": "本地导入",
|
||||||
"settings.plugins.sources.all": "全部来源",
|
"settings.plugins.sources.all": "全部来源",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
|
"settings.plugins.configuration": "插件設定",
|
||||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||||
"settings.plugins.market.results": "{count} 個插件",
|
"settings.plugins.market.results": "{count} 個插件",
|
||||||
@@ -1262,7 +1313,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||||
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
|
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
|
||||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||||
"settings.plugins.market.retry": "重試",
|
"settings.plugins.market.retry": "重試",
|
||||||
@@ -1298,4 +1349,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||||
'plugins.authentication.continue': '繼續',
|
'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 = {
|
export const zhTWMonitorMessages = {
|
||||||
|
"monitor.systemDisks": "系統分割區({count})",
|
||||||
|
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
|
||||||
|
"monitor.diskIoTotal": "彙總",
|
||||||
|
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
|
||||||
"monitor.diskIo": "磁碟 I/O",
|
"monitor.diskIo": "磁碟 I/O",
|
||||||
"monitor.diskRead": "讀取",
|
"monitor.diskRead": "讀取",
|
||||||
"monitor.diskWrite": "寫入",
|
"monitor.diskWrite": "寫入",
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import type { Messages } from '../types';
|
import type { Messages } from '../types';
|
||||||
|
|
||||||
export const zhTWTerminalMessages: Messages = {
|
export const zhTWTerminalMessages: Messages = {
|
||||||
|
'terminal.directory.open': '目前目錄:{path}。變更目錄',
|
||||||
|
'terminal.directory.unknown': '目錄未知',
|
||||||
|
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
|
||||||
|
'terminal.directory.directories': '目錄',
|
||||||
|
'terminal.directory.parent': '..(父目錄)',
|
||||||
|
'terminal.directory.loading': '正在讀取目錄…',
|
||||||
|
'terminal.directory.empty': '沒有子目錄',
|
||||||
|
'terminal.directory.noMatches': '沒有符合的子目錄',
|
||||||
|
'terminal.directory.hidden': '顯示隱藏目錄',
|
||||||
|
'terminal.directory.refresh': '重新整理',
|
||||||
|
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
|
||||||
|
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
|
||||||
|
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
|
||||||
|
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
|
||||||
|
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
|
||||||
|
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
|
||||||
|
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
|
||||||
|
|
||||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||||
|
|||||||
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
|
|||||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||||
|
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
|
||||||
|
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
|
||||||
|
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
|
||||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||||
'hostDetails.backspaceBehavior.default': '預設',
|
'hostDetails.backspaceBehavior.default': '預設',
|
||||||
'hostDetails.jumpHosts': '透過主機代理',
|
'hostDetails.jumpHosts': '透過主機代理',
|
||||||
@@ -885,6 +888,7 @@ export const zhTWVaultMessages: Messages = {
|
|||||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||||
'cloudSync.localBackups.title': '本機備份歷史',
|
'cloudSync.localBackups.title': '本機備份歷史',
|
||||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||||
|
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
|
||||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||||
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
|
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
|
||||||
'cloudSync.localBackups.maxCount': '最多保留',
|
'cloudSync.localBackups.maxCount': '最多保留',
|
||||||
@@ -899,6 +903,7 @@ export const zhTWVaultMessages: Messages = {
|
|||||||
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
|
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
|
||||||
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
|
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
|
||||||
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
|
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
|
||||||
|
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
|
||||||
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
|
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
|
||||||
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
|
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
|
||||||
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
|
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
|
||||||
|
|||||||
@@ -1,17 +1,78 @@
|
|||||||
import en, { type Messages } from './locales/en';
|
import en, { type Messages } from './locales/en';
|
||||||
import zhCN from './locales/zh-CN';
|
|
||||||
import zhTW from './locales/zh-TW';
|
|
||||||
import ru from './locales/ru';
|
|
||||||
import es from './locales/es';
|
|
||||||
|
|
||||||
// Keep keys stable; add new locales by adding another import and map entry.
|
// Keep keys stable; add new locales by adding a loader below and an entry in
|
||||||
|
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
|
||||||
export { type Messages };
|
export { type Messages };
|
||||||
|
|
||||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
/**
|
||||||
en,
|
* Lookup order for the "same base language" fallback in I18nProvider (for
|
||||||
ru,
|
* example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
|
||||||
es,
|
* order of the old static MESSAGES_BY_LOCALE map.
|
||||||
'zh-CN': zhCN,
|
*/
|
||||||
'zh-TW': zhTW,
|
export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only English is bundled with the startup chunk; other locales are loaded on
|
||||||
|
* demand. All five dictionaries used to be parsed by every window at startup
|
||||||
|
* (~1.3 MB of the entry chunk) even though one locale is in use.
|
||||||
|
*/
|
||||||
|
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
|
||||||
|
ru: () => import('./locales/ru'),
|
||||||
|
es: () => import('./locales/es'),
|
||||||
|
'zh-CN': () => import('./locales/zh-CN'),
|
||||||
|
'zh-TW': () => import('./locales/zh-TW'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadedMessages: Record<string, Messages> = { en };
|
||||||
|
const pendingLoads = new Map<string, Promise<void>>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let messagesVersion = 0;
|
||||||
|
|
||||||
|
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
|
||||||
|
return loadedMessages[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleMessagesVersion(): number {
|
||||||
|
return messagesVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeLocaleMessages(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same-base fallback locale that I18nProvider consults for `locale`. */
|
||||||
|
export function getBaseFallbackLocale(locale: string): string | undefined {
|
||||||
|
const base = locale.split('-')[0];
|
||||||
|
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOne(locale: string): Promise<void> {
|
||||||
|
if (loadedMessages[locale]) return Promise.resolve();
|
||||||
|
const loader = LOCALE_LOADERS[locale];
|
||||||
|
if (!loader) return Promise.resolve();
|
||||||
|
let pending = pendingLoads.get(locale);
|
||||||
|
if (!pending) {
|
||||||
|
pending = loader()
|
||||||
|
.then((module) => {
|
||||||
|
loadedMessages[locale] = module.default;
|
||||||
|
messagesVersion += 1;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingLoads.delete(locale);
|
||||||
|
});
|
||||||
|
pendingLoads.set(locale, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
|
||||||
|
export function loadLocaleMessages(locale: string): Promise<void> {
|
||||||
|
const needed = new Set([locale]);
|
||||||
|
const fallback = getBaseFallbackLocale(locale);
|
||||||
|
if (fallback) needed.add(fallback);
|
||||||
|
return Promise.all([...needed].map(loadOne)).then(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { requestAgentSessionReconnect } from './agentSessionReconnect';
|
||||||
|
|
||||||
|
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
|
||||||
|
let calls = 0;
|
||||||
|
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
|
||||||
|
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
|
||||||
|
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
|
||||||
|
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
|
||||||
|
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
|
||||||
|
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
|
||||||
|
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
|
||||||
|
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
|
||||||
|
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
type ReconnectRegistry = {
|
||||||
|
isActive: (sessionId: string) => boolean;
|
||||||
|
requestIfAvailable: (sessionId: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
|
||||||
|
export function requestAgentSessionReconnect(
|
||||||
|
sessionId: string,
|
||||||
|
status: string | undefined,
|
||||||
|
registry: ReconnectRegistry,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
|
||||||
|
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
|
||||||
|
if (status === 'connecting' || registry.isActive(sessionId)) {
|
||||||
|
return { ok: true, started: false, sessionId, status: 'reconnecting' };
|
||||||
|
}
|
||||||
|
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
|
||||||
|
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
|
||||||
|
}
|
||||||
|
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
|
||||||
|
}
|
||||||
@@ -2,14 +2,9 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AIPanelView,
|
|
||||||
AISession,
|
AISession,
|
||||||
} from "../../infrastructure/ai/types.ts";
|
} from "../../infrastructure/ai/types.ts";
|
||||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
|
||||||
import {
|
|
||||||
pruneInactiveScopedSessions,
|
|
||||||
pruneInactiveScopedTransientState,
|
|
||||||
} from "./aiScopeCleanup.ts";
|
|
||||||
|
|
||||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||||
return {
|
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", () => {
|
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
createSession("terminal-restorable", {
|
createSession("terminal-restorable", {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
|
|
||||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||||
type ActiveSessionIdMap = Record<string, string | null>;
|
|
||||||
|
|
||||||
function isInactiveScopedTarget(
|
function isInactiveScopedTarget(
|
||||||
scopeKey: string,
|
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(
|
export function pruneInactiveScopedSessions(
|
||||||
sessions: AISession[],
|
sessions: AISession[],
|
||||||
activeTargetIds: Set<string>,
|
activeTargetIds: Set<string>,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { AISession } from '../../infrastructure/ai/types';
|
||||||
|
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||||
|
import {
|
||||||
|
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||||
|
AI_SESSIONS_META_MIGRATION_KEY,
|
||||||
|
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||||
|
idbClearSessions,
|
||||||
|
idbDeleteSessionIds,
|
||||||
|
idbGetAllSessions,
|
||||||
|
idbGetMeta,
|
||||||
|
idbPutMeta,
|
||||||
|
idbPutSession,
|
||||||
|
idbPutSessions,
|
||||||
|
isIndexedDbAvailable,
|
||||||
|
loadOrMigrateAiSessions,
|
||||||
|
mirrorActiveSessionMapToIdb,
|
||||||
|
openAiSessionsIdb,
|
||||||
|
readActiveSessionMapFromIdb,
|
||||||
|
resetAiSessionsIdbForTests,
|
||||||
|
} from './aiSessionIdbStorage';
|
||||||
|
|
||||||
|
function makeSession(id: string, updatedAt: number): AISession {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
agentId: 'agent',
|
||||||
|
scope: { type: 'terminal', targetId: 't' },
|
||||||
|
messages: [],
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
} as AISession;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('module wrapper roundtrips sessions through the object stores', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
assert.equal(isIndexedDbAvailable(), true);
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
assert.equal(db.objectStoreNames.contains('sessions'), true);
|
||||||
|
assert.equal(db.objectStoreNames.contains('meta'), true);
|
||||||
|
|
||||||
|
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
|
||||||
|
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
|
||||||
|
const all = await idbGetAllSessions(db);
|
||||||
|
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||||
|
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
|
||||||
|
|
||||||
|
await idbDeleteSessionIds(db, ['chat-a']);
|
||||||
|
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
|
||||||
|
await idbClearSessions(db);
|
||||||
|
assert.deepEqual(await idbGetAllSessions(db), []);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('meta store roundtrips schema and active-map records', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
|
||||||
|
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||||
|
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
|
||||||
|
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
|
||||||
|
|
||||||
|
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
|
||||||
|
// Mirror uses its own connection.
|
||||||
|
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
|
||||||
|
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
|
||||||
|
assert.equal(result.source, 'indexeddb');
|
||||||
|
assert.equal(result.idbFailed, false);
|
||||||
|
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
|
||||||
|
// Legacy key content was not consumed for a migration when IDB has data.
|
||||||
|
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||||
|
let readCount = 0;
|
||||||
|
const result = await loadOrMigrateAiSessions(() => {
|
||||||
|
readCount += 1;
|
||||||
|
return legacyRaw;
|
||||||
|
});
|
||||||
|
assert.equal(result.source, 'legacy-localstorage');
|
||||||
|
assert.equal(result.idbFailed, false);
|
||||||
|
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
|
||||||
|
// Now in IndexedDB (one record per session).
|
||||||
|
assert.deepEqual(
|
||||||
|
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
|
||||||
|
['legacy-1', 'legacy-2'],
|
||||||
|
);
|
||||||
|
// Meta records written for the migration.
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||||
|
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
|
||||||
|
assert.ok(readCount >= 1);
|
||||||
|
// The legacy raw string itself is only read, never rewritten or removed
|
||||||
|
// — the callback is the only interface to the legacy store.
|
||||||
|
assert.equal(readCount >= 1, true);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const result = await loadOrMigrateAiSessions(() => null);
|
||||||
|
assert.equal(result.source, 'none');
|
||||||
|
assert.deepEqual(result.sessions, []);
|
||||||
|
assert.equal(result.idbFailed, false);
|
||||||
|
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
|
||||||
|
assert.equal(result.migrationPending, false);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const result = await loadOrMigrateAiSessions(() => '[]');
|
||||||
|
assert.equal(result.source, 'none');
|
||||||
|
assert.deepEqual(result.sessions, []);
|
||||||
|
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
|
||||||
|
const idb = installFakeIndexedDb({ failOpen: true });
|
||||||
|
try {
|
||||||
|
const legacy = [makeSession('legacy-chat', 1)];
|
||||||
|
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||||
|
assert.equal(result.source, 'legacy-localstorage');
|
||||||
|
assert.equal(result.idbFailed, true);
|
||||||
|
assert.deepEqual(result.sessions, legacy);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
|
||||||
|
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||||
|
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
|
||||||
|
try {
|
||||||
|
const legacy = [makeSession('legacy-chat', 1)];
|
||||||
|
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||||
|
assert.equal(isIndexedDbAvailable(), false);
|
||||||
|
assert.equal(result.source, 'legacy-localstorage');
|
||||||
|
assert.equal(result.idbFailed, false);
|
||||||
|
assert.deepEqual(result.sessions, legacy);
|
||||||
|
} finally {
|
||||||
|
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
|
||||||
|
|
||||||
|
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||||
|
const first = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||||
|
assert.equal(first.source, 'legacy-localstorage');
|
||||||
|
// The marker was persisted after the migration data became durable.
|
||||||
|
let db = await openAiSessionsIdb();
|
||||||
|
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
// The user deletes a chat; the legacy key stays behind by design.
|
||||||
|
await idbDeleteSessionIds(db, ['legacy-1']);
|
||||||
|
// Next boot: the marker is present, so the legacy key is never
|
||||||
|
// re-migrated and the deletion is not resurrected.
|
||||||
|
const second = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||||
|
assert.equal(second.source, 'indexeddb');
|
||||||
|
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
|
||||||
|
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
|
||||||
|
db = await openAiSessionsIdb();
|
||||||
|
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
// First boot with nothing anywhere records the marker.
|
||||||
|
const first = await loadOrMigrateAiSessions(() => null);
|
||||||
|
assert.equal(first.source, 'none');
|
||||||
|
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
// The user deleted every chat, but the stale legacy key is still there.
|
||||||
|
const second = await loadOrMigrateAiSessions(
|
||||||
|
() => JSON.stringify([makeSession('resurrected', 1)]),
|
||||||
|
);
|
||||||
|
assert.equal(second.source, 'none');
|
||||||
|
assert.equal(second.authoritativeEmpty, true);
|
||||||
|
assert.deepEqual(second.sessions, []);
|
||||||
|
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const primary = makeSession('primary', 3);
|
||||||
|
const diskA = makeSession('disk-a', 2);
|
||||||
|
const diskB = makeSession('disk-b', 1);
|
||||||
|
const diskIds = [diskA.id, diskB.id];
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
|
||||||
|
assert.equal(first.migrationPending, true);
|
||||||
|
assert.deepEqual(await idbGetAllSessions(db), [primary]);
|
||||||
|
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||||
|
|
||||||
|
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
|
||||||
|
await idbPutSession(db, updated);
|
||||||
|
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
|
||||||
|
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
|
||||||
|
assert.deepEqual(partial.sessions, [updated, diskA]);
|
||||||
|
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||||
|
|
||||||
|
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
|
||||||
|
assert.equal(complete.source, 'indexeddb');
|
||||||
|
assert.equal(complete.migrationPending, false);
|
||||||
|
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
|
||||||
|
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
|
||||||
|
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
|
||||||
|
await idbClearSessions(db);
|
||||||
|
const deleted = await loadOrMigrateAiSessions(() => {
|
||||||
|
assert.fail('completed migration must not retry the stale overflow markers');
|
||||||
|
}, diskIds);
|
||||||
|
assert.equal(deleted.authoritativeEmpty, true);
|
||||||
|
assert.deepEqual(deleted.sessions, []);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
await idbPutSessions(db, [makeSession('already-here', 1)]);
|
||||||
|
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
|
||||||
|
assert.equal(result.source, 'indexeddb');
|
||||||
|
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||||
|
} finally {
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import type { AISession } from '../../infrastructure/ai/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndexedDB persistence for AI chat sessions (issue #4123).
|
||||||
|
*
|
||||||
|
* The legacy store kept every session as one JSON blob in localStorage under
|
||||||
|
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
|
||||||
|
* budget was exceeded the degradation ladder dropped whole oldest sessions —
|
||||||
|
* silently destroying capped chats (~1 MB each) on every write.
|
||||||
|
*
|
||||||
|
* IndexedDB removes both halves of that failure mode: records are written
|
||||||
|
* per session (no global re-serialization), and there is no shared byte
|
||||||
|
* quota to defend, so byte-driven eviction disappears entirely.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* - db `netcatty_ai_sessions`
|
||||||
|
* - object store `sessions` (keyPath `id`): one record per AISession
|
||||||
|
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
|
||||||
|
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
|
||||||
|
*
|
||||||
|
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
|
||||||
|
* one release as a read-only fallback; it is no longer written or pruned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
|
||||||
|
export const AI_SESSIONS_IDB_VERSION = 1;
|
||||||
|
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
|
||||||
|
export const AI_SESSIONS_IDB_META_STORE = 'meta';
|
||||||
|
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
|
||||||
|
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
|
||||||
|
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
|
||||||
|
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
|
||||||
|
|
||||||
|
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
|
||||||
|
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
|
||||||
|
|
||||||
|
export function isIndexedDbAvailable(): boolean {
|
||||||
|
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||||
|
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let openPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function upgradeDatabase(db: IDBDatabase) {
|
||||||
|
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
|
||||||
|
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
|
||||||
|
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAiSessionsIdb(): Promise<IDBDatabase> {
|
||||||
|
if (!isIndexedDbAvailable()) {
|
||||||
|
return Promise.reject(new Error('IndexedDB is not available'));
|
||||||
|
}
|
||||||
|
if (!openPromise) {
|
||||||
|
openPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
|
||||||
|
request.onupgradeneeded = () => upgradeDatabase(request.result);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
// Drop the cached handle so a later open() re-establishes it instead
|
||||||
|
// of handing out a closed connection.
|
||||||
|
db.onclose = () => { openPromise = null; };
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close();
|
||||||
|
openPromise = null;
|
||||||
|
};
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
openPromise = null;
|
||||||
|
reject(request.error ?? new Error('Failed to open IndexedDB'));
|
||||||
|
};
|
||||||
|
request.onblocked = () => {
|
||||||
|
openPromise = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return openPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAiSessionsIdbForTests() {
|
||||||
|
openPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
|
||||||
|
const database = db ?? await openAiSessionsIdb();
|
||||||
|
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
|
||||||
|
const result = await requestToPromise(
|
||||||
|
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
|
||||||
|
);
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
|
||||||
|
if (sessions.length === 0) return;
|
||||||
|
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||||
|
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||||
|
for (const session of sessions) store.put(session);
|
||||||
|
await transactionDone(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
|
||||||
|
await idbPutSessions(db, [session]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||||
|
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||||
|
for (const id of ids) store.delete(id);
|
||||||
|
await transactionDone(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
|
||||||
|
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||||
|
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
|
||||||
|
await transactionDone(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
||||||
|
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
|
||||||
|
const record = await requestToPromise(
|
||||||
|
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
|
||||||
|
);
|
||||||
|
return record?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
|
||||||
|
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
|
||||||
|
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
|
||||||
|
await transactionDone(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-window change notification ──
|
||||||
|
|
||||||
|
let changeChannel: BroadcastChannel | null = null;
|
||||||
|
|
||||||
|
function getChangeChannel(): BroadcastChannel | null {
|
||||||
|
if (typeof BroadcastChannel === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
if (!changeChannel) {
|
||||||
|
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
|
||||||
|
// The channel must never keep the process alive (Node exposes unref).
|
||||||
|
(changeChannel as unknown as { unref?: () => void }).unref?.();
|
||||||
|
}
|
||||||
|
return changeChannel;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Announce a session-store change to OTHER windows (never the sender). */
|
||||||
|
export function publishAiSessionsIdbChange(): void {
|
||||||
|
try {
|
||||||
|
getChangeChannel()?.postMessage(Date.now());
|
||||||
|
} catch {
|
||||||
|
// Notification is best-effort; the write itself already succeeded.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
|
||||||
|
const channel = getChangeChannel();
|
||||||
|
if (!channel) return () => {};
|
||||||
|
const handler = () => listener();
|
||||||
|
channel.addEventListener('message', handler);
|
||||||
|
return () => channel.removeEventListener('message', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot load + one-release legacy migration ──
|
||||||
|
|
||||||
|
export interface AiSessionsBootLoadResult {
|
||||||
|
sessions: AISession[];
|
||||||
|
/** Where the returned sessions came from. */
|
||||||
|
source: 'indexeddb' | 'legacy-localstorage' | 'none';
|
||||||
|
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
|
||||||
|
idbFailed: boolean;
|
||||||
|
/** The store was already initialized before this boot; empty means deleted. */
|
||||||
|
authoritativeEmpty: boolean;
|
||||||
|
/** Marked disk-only records are still missing; keep their backup generations. */
|
||||||
|
migrationPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time store resolution, shared with tests:
|
||||||
|
* 1. IndexedDB `getAll()` — the primary store.
|
||||||
|
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
|
||||||
|
* legacy key itself stays untouched as a read-only fallback for one
|
||||||
|
* release).
|
||||||
|
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
|
||||||
|
*
|
||||||
|
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
|
||||||
|
* marker is persisted in the meta store after the first successful boot
|
||||||
|
* resolution. Once present, the legacy key is never consulted again —
|
||||||
|
* otherwise a boot whose migration ran while the legacy key could not be
|
||||||
|
* removed would re-migrate the stale legacy list and resurrect deleted
|
||||||
|
* chats. The marker also distinguishes "intentionally empty" (the user
|
||||||
|
* deleted every chat) from "uninitialized".
|
||||||
|
*
|
||||||
|
* Callers decide what to do when both sources are empty (the #4018 backup
|
||||||
|
* generations remain the last restore step in useAIState).
|
||||||
|
* The legacy reader may await disk-only recovery before migration completes.
|
||||||
|
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
|
||||||
|
*/
|
||||||
|
export async function loadOrMigrateAiSessions(
|
||||||
|
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
|
||||||
|
diskOnlySessionIds: readonly string[] = [],
|
||||||
|
): Promise<AiSessionsBootLoadResult> {
|
||||||
|
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
|
||||||
|
const ids = new Set(sessions.map(session => session.id));
|
||||||
|
return diskOnlySessionIds.some(id => !ids.has(id));
|
||||||
|
};
|
||||||
|
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
|
||||||
|
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||||
|
return {
|
||||||
|
sessions: legacy,
|
||||||
|
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
|
||||||
|
idbFailed,
|
||||||
|
authoritativeEmpty: false,
|
||||||
|
migrationPending: hasUnresolvedDiskSessions(legacy),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (!isIndexedDbAvailable()) {
|
||||||
|
return legacyFallback(false);
|
||||||
|
}
|
||||||
|
let db: IDBDatabase;
|
||||||
|
try {
|
||||||
|
db = await openAiSessionsIdb();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
|
||||||
|
return legacyFallback(true);
|
||||||
|
}
|
||||||
|
let sessions: AISession[];
|
||||||
|
try {
|
||||||
|
sessions = await idbGetAllSessions(db);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
|
||||||
|
return legacyFallback(true);
|
||||||
|
}
|
||||||
|
let marker: AiSessionsMigrationMarker | undefined;
|
||||||
|
try {
|
||||||
|
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
|
||||||
|
} catch {
|
||||||
|
marker = undefined;
|
||||||
|
}
|
||||||
|
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
|
||||||
|
if (!marker) {
|
||||||
|
// First boot on a store that already holds sessions (e.g. migrated by
|
||||||
|
// a build without the marker): record one-shot completion so the
|
||||||
|
// legacy key can never re-seed the store.
|
||||||
|
await writeMigrationMarker(db).catch(() => {});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
source: sessions.length > 0 ? 'indexeddb' : 'none',
|
||||||
|
idbFailed: false,
|
||||||
|
authoritativeEmpty: sessions.length === 0,
|
||||||
|
migrationPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Unfinished migration (empty or partially populated IDB). The legacy key is
|
||||||
|
// intentionally NOT removed: it stays as a read-only fallback for one
|
||||||
|
// release.
|
||||||
|
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||||
|
// A partial migration may already have primary/chunk records in IDB. Only
|
||||||
|
// fill missing marked IDs; never replay stale primary data over live IDB.
|
||||||
|
const existingIds = new Set(sessions.map(session => session.id));
|
||||||
|
const diskOnlyIds = new Set(diskOnlySessionIds);
|
||||||
|
const recovered = sessions.length === 0
|
||||||
|
? legacy
|
||||||
|
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
|
||||||
|
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
|
const migrationPending = hasUnresolvedDiskSessions(merged);
|
||||||
|
const result: AiSessionsBootLoadResult = {
|
||||||
|
sessions: merged,
|
||||||
|
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
|
||||||
|
idbFailed: false,
|
||||||
|
authoritativeEmpty: false,
|
||||||
|
migrationPending,
|
||||||
|
};
|
||||||
|
if (merged.length === 0) {
|
||||||
|
// Nothing to migrate: mark the store initialized so this state is
|
||||||
|
// distinguishable from "uninitialized" on the next boot.
|
||||||
|
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await idbPutSessions(db, recovered);
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||||
|
// Written AFTER the migration data is durable: a crash before this point
|
||||||
|
// leaves the marker absent and the next boot safely re-migrates.
|
||||||
|
if (!migrationPending) await writeMigrationMarker(db);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
|
||||||
|
return { ...result, idbFailed: true };
|
||||||
|
}
|
||||||
|
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiSessionsMigrationMarker {
|
||||||
|
migrationCompleted: true;
|
||||||
|
migrationAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
|
||||||
|
await idbPutMeta<AiSessionsMigrationMarker>(
|
||||||
|
db,
|
||||||
|
AI_SESSIONS_META_MIGRATION_KEY,
|
||||||
|
{ migrationCompleted: true, migrationAt: Date.now() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacySessions(raw: string | null | undefined): AISession[] {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active-session map mirror (meta store) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the active-pointer map into the IndexedDB meta store. The
|
||||||
|
* localStorage key remains written as before (small, quota-safe) and stays
|
||||||
|
* the read-mostly authority for this release; the mirror makes the pointer
|
||||||
|
* available to future store work without another migration.
|
||||||
|
*/
|
||||||
|
export async function mirrorActiveSessionMapToIdb(
|
||||||
|
map: Record<string, string | null>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the mirrored active-pointer map (boot restore aid when the
|
||||||
|
* localStorage key is missing).
|
||||||
|
*/
|
||||||
|
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
|
||||||
|
return value && typeof value === 'object' ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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', () => {
|
||||||
|
// Issue #4123: the retention cap is a session count (raised to 1000) —
|
||||||
|
// there is no byte budget any more.
|
||||||
|
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
|
||||||
|
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||||
|
const local = prepareRestoredSessions(sessions, false);
|
||||||
|
assert.equal(local.length, 1000);
|
||||||
|
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,162 @@
|
|||||||
|
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 }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the live sessions store once, returning both whether it is usable and
|
||||||
|
* its sessions. The history is often several MB; the helpers below used to
|
||||||
|
* parse it twice per call, and mount ran them back to back (five parses).
|
||||||
|
*/
|
||||||
|
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
|
||||||
|
if (raw == null || raw === '') return { valid: false, sessions: [] };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? { valid: true, sessions: parsed as AISession[] }
|
||||||
|
: { valid: false, sessions: [] };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, sessions: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
return !parseSessionsStore(raw).valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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[] {
|
||||||
|
return parseSessionsStore(raw).sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,13 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
|||||||
import {
|
import {
|
||||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||||
STORAGE_KEY_AI_SESSIONS,
|
STORAGE_KEY_AI_SESSIONS,
|
||||||
|
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
|
import {
|
||||||
|
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||||
|
normalizeAiSessionMessageCap,
|
||||||
|
} from '../../domain/aiSessionMessageCap';
|
||||||
import type {
|
import type {
|
||||||
AIDraft,
|
AIDraft,
|
||||||
AIPanelView,
|
AIPanelView,
|
||||||
@@ -11,7 +16,6 @@ import type {
|
|||||||
AIPermissionMode,
|
AIPermissionMode,
|
||||||
AIToolIntegrationMode,
|
AIToolIntegrationMode,
|
||||||
} from '../../infrastructure/ai/types';
|
} from '../../infrastructure/ai/types';
|
||||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
|
||||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||||
import {
|
import {
|
||||||
bumpDraftMutationVersionState,
|
bumpDraftMutationVersionState,
|
||||||
@@ -20,14 +24,26 @@ import {
|
|||||||
} from './aiDraftState';
|
} from './aiDraftState';
|
||||||
import {
|
import {
|
||||||
pruneInactiveScopedSessions,
|
pruneInactiveScopedSessions,
|
||||||
pruneInactiveScopedTransientState,
|
pruneInactiveScopedState,
|
||||||
} from './aiScopeCleanup';
|
} from './aiScopeCleanup';
|
||||||
import { emitAIStateChanged } from './aiStateEvents';
|
import { emitAIStateChanged } from './aiStateEvents';
|
||||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||||
|
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||||
|
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||||
|
import {
|
||||||
|
idbDeleteSessionIds,
|
||||||
|
idbPutMeta,
|
||||||
|
idbPutSessions,
|
||||||
|
isIndexedDbAvailable,
|
||||||
|
openAiSessionsIdb,
|
||||||
|
publishAiSessionsIdbChange,
|
||||||
|
resetAiSessionsIdbForTests,
|
||||||
|
} from './aiSessionIdbStorage';
|
||||||
|
|
||||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||||
export interface AIBridge {
|
export interface AIBridge {
|
||||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||||
|
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
|
||||||
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
|
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
|
||||||
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
|
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
|
||||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||||
@@ -35,6 +51,11 @@ export interface AIBridge {
|
|||||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||||
aiMcpSetMaxIterations?: (maxIterations: 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() {
|
export function getAIBridge() {
|
||||||
@@ -54,11 +75,29 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
|
|||||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session ids explicitly deleted by the user (or by an authoritative cloud
|
||||||
|
* merge). Their disappearance from the persisted set must not trigger the
|
||||||
|
* pre-eviction backup generation — only cap-driven drops should.
|
||||||
|
*/
|
||||||
|
const intentionallyDeletedSessionIds = new Set<string>();
|
||||||
|
|
||||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||||
const bridge = getAIBridge();
|
const bridge = getAIBridge();
|
||||||
if (sessionIds.length === 0) return;
|
if (sessionIds.length === 0) return;
|
||||||
|
// Only the startup-light handle is imported here; the runtime registers it
|
||||||
|
// when created. Without a runtime there is no in-memory runtime state, but
|
||||||
|
// the reversible-privacy flag (set outside the runtime) and the
|
||||||
|
// main-process privacy mapping may still exist, so always clear those.
|
||||||
|
const runtime = peekAgentRuntimeCleanup();
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
getAgentRuntime().clearChatSession(sessionId);
|
intentionallyDeletedSessionIds.add(sessionId);
|
||||||
|
if (runtime) {
|
||||||
|
runtime.clearChatSession(sessionId);
|
||||||
|
} else {
|
||||||
|
setReversibleModelPrivacy(sessionId, false);
|
||||||
|
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
|
||||||
|
}
|
||||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||||
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -66,8 +105,9 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
|||||||
|
|
||||||
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
|
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
|
||||||
const bridge = getAIBridge();
|
const bridge = getAIBridge();
|
||||||
|
const runtime = peekAgentRuntimeCleanup();
|
||||||
for (const terminalSessionId of new Set(terminalSessionIds)) {
|
for (const terminalSessionId of new Set(terminalSessionIds)) {
|
||||||
getAgentRuntime().clearTerminalSession(terminalSessionId);
|
runtime?.clearTerminalSession(terminalSessionId);
|
||||||
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
|
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +122,16 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
|||||||
return activeTargetIds.has(targetId);
|
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>) {
|
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||||
const currentSessions = latestAISessionsSnapshot
|
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
// Child maintenance effects run before the provider's boot effects. Keep
|
||||||
?? [];
|
// the live targets for post-hydration cleanup without adopting stale legacy
|
||||||
|
// history as authoritative state while IndexedDB is still loading.
|
||||||
|
if (!isAiSessionsHydrationComplete()) return;
|
||||||
|
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
|
||||||
|
|
||||||
// Sessions shown by a still-live scope must be protected from cleanup
|
// Sessions shown by a still-live scope must be protected from cleanup
|
||||||
// even when their own `scope.targetId` points at a closed terminal —
|
// even when their own `scope.targetId` points at a closed terminal —
|
||||||
@@ -111,224 +157,114 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
|||||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
|
||||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
// instead of stripping its record or pointer. `/chats` lists dangling chats
|
||||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
// 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);
|
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
// Issue #4015 R2: keep active-pointer entries for scopes whose target
|
||||||
let activeSessionMapChanged = false;
|
// closed. Clearing them used to make dangling chats invisible (rank 0) and
|
||||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
|
||||||
|
|
||||||
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;
|
|
||||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
const prunedScopedState = pruneInactiveScopedState(
|
||||||
currentActiveSessionIdMap,
|
|
||||||
currentDraftsByScope,
|
currentDraftsByScope,
|
||||||
currentPanelViewByScope,
|
currentPanelViewByScope,
|
||||||
activeTargetIds,
|
activeTargetIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
|
||||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
|
||||||
localStorageAdapter.write(
|
|
||||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
|
||||||
prunedScopedTransientState.activeSessionIdMap,
|
|
||||||
);
|
|
||||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
|
||||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
if (scopeKey in prunedScopedState.draftsByScope) continue;
|
||||||
bumpDraftMutationVersion(scopeKey);
|
bumpDraftMutationVersion(scopeKey);
|
||||||
bumpDraftUploadGeneration(scopeKey);
|
bumpDraftUploadGeneration(scopeKey);
|
||||||
}
|
}
|
||||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
|
||||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
|
||||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
if (scopeKey in prunedScopedState.panelViewByScope) continue;
|
||||||
bumpDraftMutationVersion(scopeKey);
|
bumpDraftMutationVersion(scopeKey);
|
||||||
}
|
}
|
||||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
|
||||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Maximum number of sessions to keep in localStorage. */
|
|
||||||
const MAX_STORED_SESSIONS = 50;
|
|
||||||
/** Maximum number of messages per session when persisting to localStorage. */
|
|
||||||
const MAX_SESSION_MESSAGES = 200;
|
|
||||||
/**
|
/**
|
||||||
* Byte budget for the serialized sessions JSON. The localStorage quota is
|
* Maximum number of sessions kept in the persisted store. IndexedDB has no
|
||||||
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
|
* shared byte quota, so retention is a plain count cap; the cap only exists
|
||||||
* of KB per turn, so keep the sessions blob well under the quota with
|
* to keep history from growing without bound.
|
||||||
* headroom for the rest of the app's storage keys.
|
|
||||||
*/
|
*/
|
||||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
const MAX_STORED_SESSIONS = 1000;
|
||||||
/** Retry budgets used when the primary budget still fails to persist. */
|
|
||||||
const RETRY_SESSIONS_JSON_BYTES = [
|
|
||||||
1024 * 1024,
|
|
||||||
512 * 1024,
|
|
||||||
256 * 1024,
|
|
||||||
128 * 1024,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
* Per-session message cap, read from user settings (clamped to 50–2000,
|
||||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
* default 200). Read on every write so a lowered cap trims the oldest
|
||||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
* messages on the next write; a raised cap stops further trimming.
|
||||||
* this replay bulk keeps every visible conversation persisted instead of
|
* Best-effort: an unavailable storage layer keeps the default cap.
|
||||||
* evicting whole chats that happen to carry it.
|
|
||||||
*/
|
*/
|
||||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
export function readAiSessionMessageCap(): number {
|
||||||
|
try {
|
||||||
function truncatePersistedToolResultContent(content: string): string {
|
return normalizeAiSessionMessageCap(
|
||||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
);
|
||||||
// result serialize at the same (or larger) size than the original — the
|
} catch {
|
||||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||||
// head+tail until the whole persisted copy fits the cap.
|
|
||||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
|
||||||
let omitted = content.length - 2 * half;
|
|
||||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
|
||||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
|
||||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
|
||||||
omitted = content.length - 2 * half;
|
|
||||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
|
||||||
}
|
}
|
||||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
* Prune sessions before writing to the persisted store to bound retention.
|
||||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
* Only affects what is persisted — the in-memory state retains all messages
|
||||||
* Responses turns can replay prior reasoning items; tool output dominates
|
* until the session is reloaded.
|
||||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
|
||||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
|
||||||
* reasoning replay and full tool output for affected messages.
|
|
||||||
*/
|
|
||||||
function stripReasoningEncryptedContent(
|
|
||||||
options: ProviderContinuationOptions,
|
|
||||||
): ProviderContinuationOptions | undefined {
|
|
||||||
const hasCiphertext = Object.values(options).some(
|
|
||||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
|
||||||
);
|
|
||||||
if (!hasCiphertext) return options;
|
|
||||||
const stripped: ProviderContinuationOptions = {};
|
|
||||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
|
||||||
const rest = { ...providerOptions };
|
|
||||||
delete rest.reasoningEncryptedContent;
|
|
||||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
|
||||||
}
|
|
||||||
return Object.keys(stripped).length ? stripped : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
|
||||||
const continuation = message.providerContinuation;
|
|
||||||
if (!continuation?.reasoningParts) return message;
|
|
||||||
let changed = false;
|
|
||||||
const parts = continuation.reasoningParts.map(part => {
|
|
||||||
if (!part.providerOptions) return part;
|
|
||||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
|
||||||
if (providerOptions === part.providerOptions) return part;
|
|
||||||
changed = true;
|
|
||||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
|
||||||
});
|
|
||||||
if (!changed) return message;
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
providerContinuation: {
|
|
||||||
...continuation,
|
|
||||||
reasoningParts: parts,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
|
||||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
|
||||||
const toolResults = strippedContinuationMessage.toolResults;
|
|
||||||
if (!toolResults?.length) return strippedContinuationMessage;
|
|
||||||
let toolResultsChanged = false;
|
|
||||||
const nextToolResults = toolResults.map((result) => {
|
|
||||||
const content = truncatePersistedToolResultContent(result.content);
|
|
||||||
if (content === result.content) return result;
|
|
||||||
toolResultsChanged = true;
|
|
||||||
return { ...result, content };
|
|
||||||
});
|
|
||||||
return toolResultsChanged
|
|
||||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
|
||||||
: strippedContinuationMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
|
||||||
let changed = false;
|
|
||||||
const messages = session.messages.map(message => {
|
|
||||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
|
||||||
if (stripped !== message) changed = true;
|
|
||||||
return stripped;
|
|
||||||
});
|
|
||||||
return changed ? { ...session, messages } : session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
|
||||||
const compactedMessageCount = Math.min(
|
|
||||||
session.messages.length,
|
|
||||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
|
||||||
);
|
|
||||||
if (compactedMessageCount === 0) return session;
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
const messages = session.messages.map((message, index) => {
|
|
||||||
if (index >= compactedMessageCount) return message;
|
|
||||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
|
||||||
if (stripped !== message) changed = true;
|
|
||||||
return stripped;
|
|
||||||
});
|
|
||||||
return changed ? { ...session, messages } : session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prune sessions before writing to localStorage to prevent hitting the
|
|
||||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
|
||||||
* state retains all messages until the session is reloaded.
|
|
||||||
*
|
*
|
||||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
* - Trims each session's messages to the last `messageCap` messages.
|
||||||
*/
|
*/
|
||||||
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
export function pruneSessionsForStorage(
|
||||||
|
sessions: AISession[],
|
||||||
|
messageCap: number = readAiSessionMessageCap(),
|
||||||
|
): AISession[] {
|
||||||
// Sort by updatedAt descending so we keep the newest
|
// Sort by updatedAt descending so we keep the newest
|
||||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||||
return limited.map(s => {
|
return limited.map(s => {
|
||||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
if (s.messages.length > messageCap) {
|
||||||
// Do not start the retained tail with a tool result whose assistant call
|
// Do not start the retained tail with a tool result whose assistant call
|
||||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||||
// compaction, even if that retains a few more than the nominal cap.
|
// compaction, even if that retains a few more than the nominal cap.
|
||||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||||
s.messages,
|
s.messages,
|
||||||
MAX_SESSION_MESSAGES,
|
messageCap,
|
||||||
);
|
);
|
||||||
const contextCompaction = s.contextCompaction
|
const contextCompaction = s.contextCompaction
|
||||||
? {
|
? {
|
||||||
@@ -349,179 +285,234 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
|
||||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
}
|
||||||
* sessions that JSON represents.
|
|
||||||
*
|
|
||||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
|
||||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
|
||||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
|
||||||
* emptied history for users under storage pressure.
|
|
||||||
*/
|
|
||||||
export function serializeSessionsForStorage(
|
|
||||||
sessions: AISession[],
|
|
||||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
|
||||||
): { json: string; sessions: AISession[] } {
|
|
||||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
|
||||||
// These messages are replaced by the durable summary before every later
|
|
||||||
// request, so their replay ciphertext can never be used again. Remove it
|
|
||||||
// before deciding whether the newest session deserves full protection;
|
|
||||||
// otherwise dead metadata can evict other visible conversations.
|
|
||||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
|
||||||
return { session, json: JSON.stringify(session) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check whether retained history fits before preparing degradation. Use
|
// ── IndexedDB persistence (issue #4123) ──
|
||||||
// the existing JSON string-length budget including array delimiters, and
|
//
|
||||||
// avoid preparing unused stripped messages and serializing payloads again.
|
// Sessions are persisted one record per session. There is no global byte
|
||||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
|
||||||
+ Math.max(0, prepared.length - 1);
|
// to defend a shared quota against the cost of serializing the whole store on
|
||||||
if (fullLength <= budgetBytes) {
|
// every write, and both are gone with per-record writes. Only a count cap
|
||||||
return {
|
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
|
||||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
|
||||||
sessions: prepared.map(entry => entry.session),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reuse the full representation when the unchanged degradation ladder is
|
/** Last set of sessions known to be persisted, for per-record diffing. */
|
||||||
// needed, including on quota retries with a smaller budget.
|
let lastPersistedSessionsById: Map<string, AISession> = new Map();
|
||||||
const serialized = prepared.map(({ session, json }) => {
|
/** Serialized write queue: IDB operations never interleave. */
|
||||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
let idbWriteQueue: Promise<void> = Promise.resolve();
|
||||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
|
||||||
if (degradedMessage === message) return [];
|
|
||||||
return [{
|
|
||||||
index,
|
|
||||||
degradedMessage,
|
|
||||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
json,
|
|
||||||
strippedSession,
|
|
||||||
strippedJson: strippedSession === session
|
|
||||||
? json
|
|
||||||
: JSON.stringify(strippedSession),
|
|
||||||
degradableMessages,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Preserve the newest session's full continuation whenever it can fit by
|
/** Test hook: reset diff state between tests. */
|
||||||
// itself. That session is the one the user is most likely continuing now;
|
export function __resetIdbPersistenceForTests() {
|
||||||
// older visible history must not make its next tool turn unreplayable.
|
lastPersistedSessionsById = new Map();
|
||||||
const protectNewestContinuation = serialized.length > 0
|
idbWriteQueue = Promise.resolve();
|
||||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
// Tests swap the fake indexedDB factory between cases; the cached open
|
||||||
|
// handle must not survive the swap.
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
// Tests simulate the post-boot state by default; the boot-gating cases
|
||||||
|
// call __resetAiSessionsHydrationForTests() to go back to pending.
|
||||||
|
markAiSessionsHydrationComplete();
|
||||||
|
}
|
||||||
|
|
||||||
// Determine how many sessions can fit using the smallest representation for
|
/** Test hook: resolve once every queued IndexedDB sync has settled. */
|
||||||
// older sessions while reserving the newest session's full representation
|
export function __flushIdbWritesForTests(): Promise<void> {
|
||||||
// when possible. This also prevents an old, oversized visible chat that must
|
return idbWriteQueue.then(() => undefined, () => undefined);
|
||||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
}
|
||||||
let minimalLength = 2
|
|
||||||
+ serialized.reduce((total, entry, index) => (
|
|
||||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
|
||||||
), 0)
|
|
||||||
+ Math.max(0, serialized.length - 1);
|
|
||||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
|
||||||
const removed = serialized.pop();
|
|
||||||
if (!removed) break;
|
|
||||||
minimalLength -= removed.strippedJson.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then keep full continuation data for the retained sessions and shed
|
// ── Boot hydration signal (issue #4125 review) ──
|
||||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
//
|
||||||
// retained sessions only as needed, never touching the protected newest
|
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
|
||||||
// session. Within one session, shed it one message at a time from oldest to
|
// migration, disk-backup restore). Sync collection and the active-pointer
|
||||||
// newest so a long active chat can retain its most recent replayable tool
|
// validation must not run before it: a pre-hydration collect reads the
|
||||||
// exchange when that still fits.
|
// stale legacy localStorage store and would overwrite newer IndexedDB
|
||||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
// records, and pointer validation against the initially-empty in-memory
|
||||||
+ Math.max(0, serialized.length - 1);
|
// array would null every pointer before the real sessions arrive.
|
||||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
|
||||||
for (
|
|
||||||
let index = serialized.length - 1;
|
|
||||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
|
||||||
index -= 1
|
|
||||||
) {
|
|
||||||
const current = serialized[index];
|
|
||||||
let degradedMessageCount = 0;
|
|
||||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
|
||||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
|
||||||
degradedMessageCount += 1;
|
|
||||||
}
|
|
||||||
if (degradedMessageCount > 0) {
|
|
||||||
const degradedMessagesByIndex = new Map(
|
|
||||||
current.degradableMessages
|
|
||||||
.slice(0, degradedMessageCount)
|
|
||||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
|
||||||
);
|
|
||||||
const nextSession = {
|
|
||||||
...current.session,
|
|
||||||
messages: current.session.messages.map((message, messageIndex) => (
|
|
||||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
const nextJson = JSON.stringify(nextSession);
|
|
||||||
const projectedJsonLength = current.json.length
|
|
||||||
+ current.degradableMessages
|
|
||||||
.slice(0, degradedMessageCount)
|
|
||||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
|
||||||
jsonLength += nextJson.length - projectedJsonLength;
|
|
||||||
serialized[index] = {
|
|
||||||
...current,
|
|
||||||
session: nextSession,
|
|
||||||
json: nextJson,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
let aiSessionsHydrationComplete = false;
|
||||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
|
||||||
sessions: serialized.map(entry => entry.session),
|
export function isAiSessionsHydrationComplete(): boolean {
|
||||||
};
|
return aiSessionsHydrationComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by useAIState's boot effect when the boot load settles. */
|
||||||
|
export function markAiSessionsHydrationComplete(): void {
|
||||||
|
aiSessionsHydrationComplete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
|
||||||
|
export function __resetAiSessionsHydrationForTests(): void {
|
||||||
|
aiSessionsHydrationComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the diff baseline after a boot load or migration. */
|
||||||
|
export function seedLastPersistedSessions(sessions: AISession[]) {
|
||||||
|
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist sessions to localStorage with byte-budgeted pruning and retries.
|
* Read the persisted session set synchronously. Source of truth is the
|
||||||
* Returns true when the write succeeded; a false result means the payload
|
* in-memory snapshot held by useAIState; this accessor returns the last set
|
||||||
* could not be persisted even after escalation (it stays memory-only).
|
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
|
||||||
|
* localStorage store (kept untouched for one release).
|
||||||
*/
|
*/
|
||||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
export function getPersistedAiSessionsSnapshot(): AISession[] {
|
||||||
// Synced records are authoritative after restart: silently evicting a
|
if (lastPersistedSessionsById.size > 0) {
|
||||||
// session or turn here would turn local retention into cloud deletion.
|
return Array.from(lastPersistedSessionsById.values())
|
||||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
|
||||||
}
|
}
|
||||||
let previousLength = Number.POSITIVE_INFINITY;
|
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
}
|
||||||
// A real quota failure means the next attempt must be smaller even when
|
|
||||||
// the failed payload was already below the nominal retry budget. Reduce
|
function queueIdbSessionSync(
|
||||||
// materially on each bounded retry so several small sessions can be
|
changed: AISession[],
|
||||||
// removed before the retry sequence is exhausted.
|
removedIds: string[],
|
||||||
const reducedBudget = Number.isFinite(previousLength)
|
prevById: Map<string, AISession>,
|
||||||
? Math.floor(previousLength * 0.75)
|
persisted: AISession[],
|
||||||
: configuredBudget;
|
) {
|
||||||
const budget = Math.min(configuredBudget, reducedBudget);
|
if (changed.length === 0 && removedIds.length === 0) return;
|
||||||
if (budget < 2) break;
|
idbWriteQueue = idbWriteQueue
|
||||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
.then(async () => {
|
||||||
if (candidate.json.length >= previousLength) continue;
|
if (!isIndexedDbAvailable()) return;
|
||||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
const db = await openAiSessionsIdb();
|
||||||
previousLength = candidate.json.length;
|
// Per-record write: only the changed sessions are put, only removed
|
||||||
|
// ids are deleted.
|
||||||
|
if (changed.length > 0) await idbPutSessions(db, changed);
|
||||||
|
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
|
||||||
|
await idbPutMeta(db, 'schemaVersion', 1);
|
||||||
|
await idbPutMeta(db, 'lastWriteAt', Date.now());
|
||||||
|
publishAiSessionsIdbChange();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// IndexedDB failures must never break the in-memory session state.
|
||||||
|
// Reset the diff baseline so the next write retries every record, and
|
||||||
|
// fall back to the legacy localStorage key so a corrupt/unavailable
|
||||||
|
// IndexedDB still leaves a restorable trace (via the disk mirror).
|
||||||
|
console.warn('[AIState] IndexedDB session sync failed:', error);
|
||||||
|
lastPersistedSessionsById = new Map(prevById);
|
||||||
|
try {
|
||||||
|
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
|
||||||
|
} catch {
|
||||||
|
// Legacy fallback is best-effort too.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durability mirror (issue #4015 follow-up): every write also schedules a
|
||||||
|
// debounced disk backup so a wiped/recreated profile does not take the chat
|
||||||
|
// history with it. The persisted JSON is mirrored; when the count cap evicts
|
||||||
|
// sessions, a PRE-eviction generation is written first so evicted chats stay
|
||||||
|
// recoverable from disk (the blind spot found in issue #4123).
|
||||||
|
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||||
|
try {
|
||||||
|
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Backup failures must never break chat persistence.
|
||||||
}
|
}
|
||||||
// Last resort: attempt the smallest representation this serializer can
|
});
|
||||||
// produce (the newest session with replay-only ciphertext removed). A very
|
|
||||||
// small amount of shared quota may still be enough to preserve that chat.
|
let aiSessionsBackupRecoveryPending = false;
|
||||||
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
|
|
||||||
if (
|
/** Partial migration must not rotate away generations holding disk-only chats. */
|
||||||
minimalCandidate.json.length < previousLength
|
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
|
||||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
aiSessionsBackupRecoveryPending = pending;
|
||||||
) {
|
if (pending) backupMirror.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushBackupMirror() {
|
||||||
|
try {
|
||||||
|
backupMirror.flushNow();
|
||||||
|
} catch {
|
||||||
|
// Unload and persistence must remain fail-safe.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetBackupMirrorForTests() {
|
||||||
|
backupMirror.reset();
|
||||||
|
aiSessionsBackupRecoveryPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleBackupMirror(json: string) {
|
||||||
|
if (aiSessionsBackupRecoveryPending) return;
|
||||||
|
try {
|
||||||
|
backupMirror.schedule(json);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write one immediate backup generation (bypasses the debounce window). */
|
||||||
|
function writeImmediateBackupGeneration(json: string) {
|
||||||
|
if (aiSessionsBackupRecoveryPending) return;
|
||||||
|
try {
|
||||||
|
backupMirror.schedule(json);
|
||||||
|
backupMirror.flushNow();
|
||||||
|
} catch {
|
||||||
|
// Backup failures must never break chat persistence.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perf (#4121): the exact same immutable sessions array is often written
|
||||||
|
* again by the debounced save path and by cloud-sync collection; skipping
|
||||||
|
* the re-stringify for a reference-identical set keeps that cheap. The
|
||||||
|
* per-record IDB diff below already makes the store write itself cheap.
|
||||||
|
*/
|
||||||
|
let lastMirroredSessions: AISession[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist sessions. Writes go to IndexedDB, one record per session, through a
|
||||||
|
* serialized queue; errors are logged, never thrown. Returns true when the
|
||||||
|
* persisted set was computed (in-memory state remains authoritative either
|
||||||
|
* way).
|
||||||
|
*/
|
||||||
|
export function writeSessionsForStorage(
|
||||||
|
sessions: AISession[],
|
||||||
|
options?: { deletedSessionIds?: readonly string[] },
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
// Explicitly deleted ids never count as cap eviction (they also must not
|
||||||
|
// trigger a pre-eviction backup generation).
|
||||||
|
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
|
||||||
|
// Synced records are authoritative after restart: never prune them, or
|
||||||
|
// local retention would turn into cloud deletion on the next sync.
|
||||||
|
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
|
||||||
|
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||||
|
)?.enabled;
|
||||||
|
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||||
|
|
||||||
|
const prevById = lastPersistedSessionsById;
|
||||||
|
const persistedIds = new Set(persisted.map(session => session.id));
|
||||||
|
|
||||||
|
// Count-cap eviction: sessions that drop out of the persisted set without
|
||||||
|
// being explicitly deleted get a pre-eviction disk generation containing
|
||||||
|
// the full pre-eviction store, so they remain recoverable.
|
||||||
|
const evicted = Array.from(prevById.entries())
|
||||||
|
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
|
||||||
|
.map(([, session]) => session);
|
||||||
|
intentionallyDeletedSessionIds.clear();
|
||||||
|
if (evicted.length > 0) {
|
||||||
|
writeImmediateBackupGeneration(JSON.stringify(sessions));
|
||||||
|
console.warn(
|
||||||
|
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-record diff (synchronous: the baseline must advance even when
|
||||||
|
// IndexedDB is unavailable, and callers read the snapshot right after).
|
||||||
|
const changed = persisted.filter(session => prevById.get(session.id) !== session);
|
||||||
|
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
|
||||||
|
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
|
||||||
|
|
||||||
|
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
|
||||||
|
scheduleBackupMirror(JSON.stringify(persisted));
|
||||||
|
lastMirroredSessions = persisted;
|
||||||
|
}
|
||||||
|
queueIdbSessionSync(changed, removedIds, prevById, persisted);
|
||||||
return true;
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] Failed to persist AI sessions:', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
console.warn(
|
|
||||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||||
@@ -531,6 +522,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
|||||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||||
let latestAIDraftUploadGenerationByScopeSnapshot: 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[]) {
|
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||||
latestAISessionsSnapshot = sessions;
|
latestAISessionsSnapshot = sessions;
|
||||||
}
|
}
|
||||||
@@ -541,10 +554,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
|||||||
|
|
||||||
export function prewarmAIStateStorageSnapshots() {
|
export function prewarmAIStateStorageSnapshots() {
|
||||||
try {
|
try {
|
||||||
if (latestAISessionsSnapshot === null) {
|
// Sessions are no longer prewarmed from localStorage: the primary store is
|
||||||
latestAISessionsSnapshot =
|
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
|
||||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
// read-only legacy key would make the boot loader skip the IndexedDB
|
||||||
}
|
// contents ("never restore over non-empty live state").
|
||||||
if (latestAIActiveSessionMapSnapshot === null) {
|
if (latestAIActiveSessionMapSnapshot === null) {
|
||||||
latestAIActiveSessionMapSnapshot =
|
latestAIActiveSessionMapSnapshot =
|
||||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import type { HistoryEntry } from "./commandHistoryStore";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
STORAGE_KEY_COMMAND_HISTORY,
|
STORAGE_KEY_COMMAND_HISTORY,
|
||||||
@@ -73,7 +74,9 @@ const {
|
|||||||
getCommandHistoryClearGeneration,
|
getCommandHistoryClearGeneration,
|
||||||
hasCommandHistoryBeenCleared,
|
hasCommandHistoryBeenCleared,
|
||||||
markCommandHistoryCleared,
|
markCommandHistoryCleared,
|
||||||
|
fuzzyQueryHistory,
|
||||||
queryHistory,
|
queryHistory,
|
||||||
|
queryRecentHistoryByCommand,
|
||||||
recordCommand,
|
recordCommand,
|
||||||
subscribeCommandHistoryCleared,
|
subscribeCommandHistoryCleared,
|
||||||
} = await import("./commandHistoryStore.ts");
|
} = await import("./commandHistoryStore.ts");
|
||||||
@@ -326,3 +329,172 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
|
|||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
|
||||||
|
clearHistory();
|
||||||
|
recordCommand("echo fast-path", "host-fast", "linux");
|
||||||
|
assert.equal(flushCommandHistoryStore(), true);
|
||||||
|
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
|
||||||
|
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
|
||||||
|
|
||||||
|
const parse = t.mock.method(JSON, "parse");
|
||||||
|
recordCommand("echo fast-path-2", "host-fast", "linux");
|
||||||
|
assert.equal(flushCommandHistoryStore(), true);
|
||||||
|
// A peer window's save only needs the generation as well.
|
||||||
|
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
|
||||||
|
assert.equal(parse.mock.callCount(), 0);
|
||||||
|
parse.mock.restore();
|
||||||
|
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const QUERY_NOW = 1_700_000_000_000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
|
||||||
|
return {
|
||||||
|
command, hostId: "host-1", os: "linux", frequency: 1,
|
||||||
|
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installHistoryEntries(entries: HistoryEntry[]): void {
|
||||||
|
clearHistory();
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
|
||||||
|
});
|
||||||
|
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||||
|
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
|
||||||
|
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||||
|
const entries = [
|
||||||
|
historyEntry("vi", { frequency: 100 }),
|
||||||
|
historyEntry("vim duplicate"),
|
||||||
|
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||||
|
historyEntry("Vim case"),
|
||||||
|
historyEntry("vim tied"),
|
||||||
|
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
|
||||||
|
historyEntry("unrelated", { frequency: 100 }),
|
||||||
|
];
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
|
||||||
|
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
|
||||||
|
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
|
||||||
|
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
|
||||||
|
assert.deepEqual(queryHistory("missing"), []);
|
||||||
|
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
|
||||||
|
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||||
|
const entries = [
|
||||||
|
historyEntry("vim a", { frequency: 2 }),
|
||||||
|
historyEntry("vim b", { frequency: 3 }),
|
||||||
|
historyEntry("vim c", { frequency: 4 }),
|
||||||
|
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
|
||||||
|
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
|
||||||
|
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
|
||||||
|
];
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
|
||||||
|
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||||
|
const entries = [
|
||||||
|
historyEntry("vi", { frequency: 100 }),
|
||||||
|
historyEntry("vi alpha"),
|
||||||
|
historyEntry("vi beta"),
|
||||||
|
historyEntry("very indirect", { frequency: 2 }),
|
||||||
|
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||||
|
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
|
||||||
|
historyEntry("iv does not match"),
|
||||||
|
];
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory(""), []);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
|
||||||
|
const entries = [
|
||||||
|
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||||
|
historyEntry("cd '/work/new folder'"),
|
||||||
|
historyEntry("CD /work/tied"),
|
||||||
|
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
|
||||||
|
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
|
||||||
|
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
|
||||||
|
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
|
||||||
|
];
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
|
||||||
|
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
|
||||||
|
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
|
||||||
|
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
|
||||||
|
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
|
||||||
|
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||||
|
const entries = [
|
||||||
|
historyEntry("av-x"),
|
||||||
|
historyEntry("avx"),
|
||||||
|
historyEntry(`v${"a".repeat(1900)}x`),
|
||||||
|
historyEntry("v-x"),
|
||||||
|
historyEntry("v/x"),
|
||||||
|
historyEntry("v_x"),
|
||||||
|
historyEntry("v x"),
|
||||||
|
historyEntry("vx adjacent"),
|
||||||
|
historyEntry("v missing second character"),
|
||||||
|
historyEntry("xv wrong order"),
|
||||||
|
];
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vx"), [
|
||||||
|
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(fuzzyQueryHistory("vv"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const size of [4_000, 40_000]) {
|
||||||
|
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
|
||||||
|
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||||
|
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
|
||||||
|
// Unsorted scores/timestamps, with the best match at the end of the store.
|
||||||
|
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
|
||||||
|
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
|
||||||
|
}));
|
||||||
|
installHistoryEntries(entries);
|
||||||
|
queryHistory("missing"); // Warm the cache before measuring query operations.
|
||||||
|
|
||||||
|
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
|
||||||
|
const pow = t.mock.method(Math, "pow");
|
||||||
|
const lower = t.mock.method(String.prototype, "toLowerCase");
|
||||||
|
// node:test cannot mock methods directly on arrays (including Array.prototype).
|
||||||
|
const originalSort = Array.prototype.sort;
|
||||||
|
const sortedSizes: number[] = [];
|
||||||
|
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
|
||||||
|
sortedSizes.push(this.length);
|
||||||
|
return originalSort.call(this, compare);
|
||||||
|
};
|
||||||
|
t.after(() => { Array.prototype.sort = originalSort; });
|
||||||
|
const queries = [
|
||||||
|
() => queryHistory("vi", { limit: 5 }),
|
||||||
|
() => fuzzyQueryHistory("vi", { limit: 5 }),
|
||||||
|
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
const beforePow = pow.mock.callCount();
|
||||||
|
const beforeLower = lower.mock.callCount();
|
||||||
|
const result = query();
|
||||||
|
assert.deepEqual(result, expected);
|
||||||
|
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
|
||||||
|
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
|
||||||
|
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
|
||||||
|
}
|
||||||
|
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -68,33 +68,75 @@ function loadStore(): HistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let saveIdleHandle: number | null = null;
|
||||||
|
|
||||||
|
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's clear generation, read from its raw JSON without parsing the
|
||||||
|
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
|
||||||
|
* clearGeneration first (serializeStore); other layouts fall back to a parse.
|
||||||
|
*/
|
||||||
|
function readRawClearGeneration(raw: string | null): number | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
|
||||||
|
if (match) return Number(match[1]);
|
||||||
|
try {
|
||||||
|
return readClearGeneration(JSON.parse(raw) as HistoryStore);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeStore(store: HistoryStore): HistoryStore {
|
||||||
|
// Keep clearGeneration first so readRawClearGeneration can find it.
|
||||||
|
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
|
||||||
|
}
|
||||||
|
|
||||||
function persistStoreNow(store: HistoryStore): boolean {
|
function persistStoreNow(store: HistoryStore): boolean {
|
||||||
// A peer clear is visible in storage before its queued event is handled.
|
// A peer clear is visible in storage before its queued event is handled.
|
||||||
// Never flush a cached pre-clear snapshot over that newer empty store.
|
// Never flush a cached pre-clear snapshot over that newer empty store.
|
||||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
// Only the generation is needed on this per-command path; parse the stored
|
||||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
// history just in the rare case where it must be adopted.
|
||||||
cachedStore = persisted;
|
const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
|
||||||
knownClearGeneration = readClearGeneration(persisted);
|
if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
|
||||||
notifyCommandHistoryCleared();
|
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||||
return true;
|
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||||
|
cachedStore = persisted;
|
||||||
|
knownClearGeneration = readClearGeneration(persisted);
|
||||||
|
notifyCommandHistoryCleared();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
|
||||||
if (ok) return true;
|
if (ok) return true;
|
||||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||||
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
||||||
return localStorageAdapter.write(STORAGE_KEY, store);
|
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingSave(): boolean {
|
||||||
|
return saveTimer !== null || saveIdleHandle !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStore(store: HistoryStore): void {
|
function saveStore(store: HistoryStore): void {
|
||||||
cachedStore = store;
|
cachedStore = store;
|
||||||
// Debounce saves to avoid excessive writes
|
// Debounce saves, then write when the renderer is idle: serializing the
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
// history right after Enter used to land in the middle of command output.
|
||||||
|
cancelPendingSave();
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
persistStoreNow(store);
|
|
||||||
saveTimer = null;
|
saveTimer = null;
|
||||||
|
const persist = () => {
|
||||||
|
saveIdleHandle = null;
|
||||||
|
persistStoreNow(store);
|
||||||
|
};
|
||||||
|
if (typeof requestIdleCallback === "function") {
|
||||||
|
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
|
||||||
|
} else {
|
||||||
|
persist();
|
||||||
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +145,10 @@ function cancelPendingSave(): void {
|
|||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = null;
|
saveTimer = null;
|
||||||
}
|
}
|
||||||
|
if (saveIdleHandle !== null) {
|
||||||
|
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
|
||||||
|
saveIdleHandle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,26 +175,14 @@ function applyExternalCommandHistoryWrite(
|
|||||||
rawNewValue: string | null,
|
rawNewValue: string | null,
|
||||||
rawOldValue: string | null,
|
rawOldValue: string | null,
|
||||||
): void {
|
): void {
|
||||||
let incomingGeneration: number | null = null;
|
// Fires in every other window for each save; read just the generation.
|
||||||
if (rawNewValue) {
|
const incomingGeneration = readRawClearGeneration(rawNewValue);
|
||||||
try {
|
|
||||||
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
|
||||||
} catch {
|
|
||||||
incomingGeneration = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let previousGeneration = knownClearGeneration;
|
let previousGeneration = knownClearGeneration;
|
||||||
if (previousGeneration === null) {
|
if (previousGeneration === null) {
|
||||||
// A fresh popup can have compose recall before its first autocomplete query.
|
// A fresh popup can have compose recall before its first autocomplete query.
|
||||||
// The event's old value is its baseline, even after an earlier global clear.
|
// The event's old value is its baseline, even after an earlier global clear.
|
||||||
try {
|
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
|
||||||
previousGeneration = rawOldValue
|
|
||||||
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
|
||||||
: 0;
|
|
||||||
} catch {
|
|
||||||
previousGeneration = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
||||||
cancelPendingSave();
|
cancelPendingSave();
|
||||||
@@ -156,7 +190,7 @@ function applyExternalCommandHistoryWrite(
|
|||||||
knownClearGeneration = incomingGeneration;
|
knownClearGeneration = incomingGeneration;
|
||||||
notifyCommandHistoryCleared();
|
notifyCommandHistoryCleared();
|
||||||
} else {
|
} else {
|
||||||
if (!saveTimer) cachedStore = null;
|
if (!hasPendingSave()) cachedStore = null;
|
||||||
if (incomingGeneration !== null && knownClearGeneration === null) {
|
if (incomingGeneration !== null && knownClearGeneration === null) {
|
||||||
knownClearGeneration = incomingGeneration;
|
knownClearGeneration = incomingGeneration;
|
||||||
}
|
}
|
||||||
@@ -318,6 +352,36 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
|||||||
argumentPrefix?: string;
|
argumentPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RankedHistoryEntry {
|
||||||
|
entry: HistoryEntry;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the best k distinct commands. Queries score each match once and
|
||||||
|
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
|
||||||
|
* for autocomplete). Entries arrive in store order for stable score ties.
|
||||||
|
*/
|
||||||
|
function retainHistoryMatch(
|
||||||
|
best: RankedHistoryEntry[],
|
||||||
|
entry: HistoryEntry,
|
||||||
|
score: number,
|
||||||
|
limit: number,
|
||||||
|
): void {
|
||||||
|
if (best.length >= limit && score <= best[best.length - 1].score) return;
|
||||||
|
|
||||||
|
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
|
||||||
|
if (duplicate >= 0) {
|
||||||
|
if (score <= best[duplicate].score) return;
|
||||||
|
best.splice(duplicate, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert after equal scores to preserve the original store order on ties.
|
||||||
|
const index = best.findIndex((candidate) => score > candidate.score);
|
||||||
|
if (best.length >= limit) best.pop();
|
||||||
|
best.splice(index < 0 ? best.length : index, 0, { entry, score });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query history entries matching a prefix.
|
* Query history entries matching a prefix.
|
||||||
* Returns entries sorted by relevance (frequency * recency).
|
* Returns entries sorted by relevance (frequency * recency).
|
||||||
@@ -332,33 +396,17 @@ export function queryHistory(
|
|||||||
const lowerPrefix = prefix.toLowerCase();
|
const lowerPrefix = prefix.toLowerCase();
|
||||||
const now = Date.now(); // Cache once per query
|
const now = Date.now(); // Cache once per query
|
||||||
|
|
||||||
const filtered = store.entries.filter((entry) => {
|
const best: RankedHistoryEntry[] = [];
|
||||||
|
for (const entry of store.entries) {
|
||||||
|
if (hostId && entry.hostId !== hostId) continue;
|
||||||
// Must match prefix
|
// Must match prefix
|
||||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||||
// Must not be identical to prefix
|
// Must not be identical to prefix
|
||||||
if (entry.command === prefix) return false;
|
if (entry.command === prefix) continue;
|
||||||
|
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
|
||||||
// Host filtering: strict per-host isolation
|
|
||||||
if (hostId) {
|
|
||||||
return entry.hostId === hostId;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by score (frequency * recency)
|
|
||||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
|
||||||
|
|
||||||
// Deduplicate by command text (keep highest scored)
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const results: HistoryEntry[] = [];
|
|
||||||
for (const entry of filtered) {
|
|
||||||
if (seen.has(entry.command)) continue;
|
|
||||||
seen.add(entry.command);
|
|
||||||
results.push(entry);
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return best.map(({ entry }) => entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,7 +424,7 @@ export function fuzzyQueryHistory(
|
|||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
const now = Date.now(); // Cache once per query
|
const now = Date.now(); // Cache once per query
|
||||||
|
|
||||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
const best: RankedHistoryEntry[] = [];
|
||||||
|
|
||||||
for (const entry of store.entries) {
|
for (const entry of store.entries) {
|
||||||
// Host filtering
|
// Host filtering
|
||||||
@@ -386,24 +434,11 @@ export function fuzzyQueryHistory(
|
|||||||
|
|
||||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||||
if (matchScore > 0 && entry.command !== query) {
|
if (matchScore > 0 && entry.command !== query) {
|
||||||
scored.push({ entry, matchScore });
|
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scored.sort((a, b) =>
|
return best.map(({ entry }) => entry);
|
||||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
|
||||||
);
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const results: HistoryEntry[] = [];
|
|
||||||
for (const { entry } of scored) {
|
|
||||||
if (seen.has(entry.command)) continue;
|
|
||||||
seen.add(entry.command);
|
|
||||||
results.push(entry);
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -428,38 +463,26 @@ export function queryRecentHistoryByCommand(
|
|||||||
const commandPrefix = `${trimmedCommandName} `;
|
const commandPrefix = `${trimmedCommandName} `;
|
||||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||||
|
|
||||||
const filtered = store.entries.filter((entry) => {
|
const best: RankedHistoryEntry[] = [];
|
||||||
|
for (const entry of store.entries) {
|
||||||
|
if (hostId && entry.hostId !== hostId) continue;
|
||||||
const lowerCommand = entry.command.toLowerCase();
|
const lowerCommand = entry.command.toLowerCase();
|
||||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
if (excludeCommand && entry.command === excludeCommand) continue;
|
||||||
|
|
||||||
if (normalizedArgumentPrefix) {
|
if (normalizedArgumentPrefix) {
|
||||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostId) {
|
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
|
||||||
return entry.hostId === hostId;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const results: HistoryEntry[] = [];
|
|
||||||
for (const entry of filtered) {
|
|
||||||
if (seen.has(entry.command)) continue;
|
|
||||||
seen.add(entry.command);
|
|
||||||
results.push(entry);
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return best.map(({ entry }) => entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentCommandToken(command: string): string {
|
function getCurrentCommandToken(command: string): string {
|
||||||
@@ -535,28 +558,28 @@ function fuzzyScore(query: string, target: string): number {
|
|||||||
if (query.length > target.length) return 0;
|
if (query.length > target.length) return 0;
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
let queryIdx = 0;
|
let searchFrom = 0;
|
||||||
let prevMatchIdx = -2;
|
let prevMatchIdx = -2;
|
||||||
|
|
||||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
|
||||||
if (target[i] === query[queryIdx]) {
|
// Skip unmatched runs in native string search, especially for long commands.
|
||||||
queryIdx++;
|
const i = target.indexOf(query[queryIdx], searchFrom);
|
||||||
// First character bonus
|
if (i < 0) return 0;
|
||||||
if (i === 0) score += 10;
|
// First character bonus
|
||||||
// Consecutive match bonus
|
if (i === 0) score += 10;
|
||||||
if (i === prevMatchIdx + 1) score += 5;
|
// Consecutive match bonus
|
||||||
// Word boundary bonus
|
if (i === prevMatchIdx + 1) score += 5;
|
||||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
// Word boundary bonus
|
||||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||||
score += 3;
|
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||||
}
|
score += 3;
|
||||||
score += 1;
|
|
||||||
prevMatchIdx = i;
|
|
||||||
}
|
}
|
||||||
|
score += 1;
|
||||||
|
prevMatchIdx = i;
|
||||||
|
searchFrom = i + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All query characters must be matched
|
return score;
|
||||||
return queryIdx === query.length ? score : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,3 +47,45 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
|
|||||||
historyId: "history",
|
historyId: "history",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("idle runtime polls do not notify subscribers until something changes", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
const { act, create } = await import("react-test-renderer");
|
||||||
|
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
const idle = (takenAt: number): MonitorSnapshot => ({
|
||||||
|
historyMode: "replace",
|
||||||
|
runtimeId: "idle-poll-test",
|
||||||
|
epoch: 7,
|
||||||
|
takenAt,
|
||||||
|
running: [],
|
||||||
|
count: 0,
|
||||||
|
hostCount: 0,
|
||||||
|
activeSessions: [],
|
||||||
|
startedAt: {},
|
||||||
|
hosts: {},
|
||||||
|
});
|
||||||
|
let renders = 0;
|
||||||
|
function Probe() {
|
||||||
|
useMonitorSnapshot();
|
||||||
|
renders += 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Later than anything earlier tests published, so this baseline wins.
|
||||||
|
const base = 1_000_000_000;
|
||||||
|
publishMonitorSnapshot(idle(base));
|
||||||
|
let renderer!: ReturnType<typeof create>;
|
||||||
|
act(() => {
|
||||||
|
renderer = create(React.createElement(Probe));
|
||||||
|
});
|
||||||
|
const baseline = renders;
|
||||||
|
act(() => {
|
||||||
|
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
|
||||||
|
});
|
||||||
|
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
|
||||||
|
act(() => {
|
||||||
|
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
|
||||||
|
});
|
||||||
|
assert.equal(renders, baseline + 1, "a real change still notifies");
|
||||||
|
act(() => renderer.unmount());
|
||||||
|
});
|
||||||
@@ -14,8 +14,30 @@ const empty: MonitorSnapshot = {
|
|||||||
};
|
};
|
||||||
let snapshot = empty;
|
let snapshot = empty;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
|
||||||
|
return value.running.length === 0
|
||||||
|
&& value.count === 0
|
||||||
|
&& value.hostCount === 0
|
||||||
|
&& value.activeSessions.length === 0
|
||||||
|
&& Object.keys(value.hosts).length === 0
|
||||||
|
&& Object.keys(value.startedAt).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
|
||||||
|
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
|
||||||
|
return isIdleMonitorSnapshot(previous)
|
||||||
|
&& isIdleMonitorSnapshot(next)
|
||||||
|
&& previous.epoch === next.epoch
|
||||||
|
&& previous.runtimeId === next.runtimeId
|
||||||
|
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
||||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
const merged = mergeMonitorSnapshotHistory(snapshot, next);
|
||||||
|
// The runtime is polled even when nothing is monitored; do not wake every
|
||||||
|
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
|
||||||
|
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
|
||||||
|
snapshot = merged;
|
||||||
for (const l of listeners) l();
|
for (const l of listeners) l();
|
||||||
}
|
}
|
||||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
|
|||||||
['tab', 'tab'],
|
['tab', 'tab'],
|
||||||
]);
|
]);
|
||||||
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||||
|
'[data-plugin-editing]',
|
||||||
'input',
|
'input',
|
||||||
'textarea',
|
'textarea',
|
||||||
'select',
|
'select',
|
||||||
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
|||||||
'.monaco-menu-container',
|
'.monaco-menu-container',
|
||||||
].join(', ');
|
].join(', ');
|
||||||
|
|
||||||
|
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
|
||||||
|
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
|
||||||
|
}
|
||||||
|
|
||||||
function hasEditableShortcutAncestor(node: unknown): boolean {
|
function hasEditableShortcutAncestor(node: unknown): boolean {
|
||||||
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
|
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
|
||||||
return typeof closest === 'function'
|
return typeof closest === 'function'
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
test('React 19 changes are staged as safe data before any host rendering', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
|
||||||
|
const saved = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
|
||||||
|
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
|
||||||
|
const { default: React, act, useState } = await import('react');
|
||||||
|
const { createRoot } = await import('react-dom/client');
|
||||||
|
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
|
||||||
|
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||||
|
const staged = createPluginRemoteTree();
|
||||||
|
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
|
||||||
|
const observer = new RemoteMutationObserver({
|
||||||
|
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
|
||||||
|
call() { throw new Error('Host method calls are unavailable'); },
|
||||||
|
});
|
||||||
|
const container = document.getElementById('remote')!;
|
||||||
|
observer.observe(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
function Example() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||||
|
const first = snapshots.at(-1)!;
|
||||||
|
const section = first.children[0]; assert.equal(section.type, 1);
|
||||||
|
if (section.type !== 1) throw new Error('Expected section');
|
||||||
|
assert.equal(section.attributes.class, 'card');
|
||||||
|
assert.match(section.attributes.style, /display:\s*grid/);
|
||||||
|
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||||
|
const updated = JSON.stringify(snapshots.at(-1));
|
||||||
|
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
|
||||||
|
assert.equal(staged.isFailed(), false);
|
||||||
|
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
|
||||||
|
assert.deepEqual(staged.snapshot(), { children: [] });
|
||||||
|
assert.equal(staged.isFailed(), true);
|
||||||
|
assert.throws(() => staged.apply([]));
|
||||||
|
} finally {
|
||||||
|
observer.disconnect(); await act(async () => { root.unmount(); });
|
||||||
|
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
|
||||||
|
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||||
|
const tree = createPluginRemoteTree();
|
||||||
|
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
|
||||||
|
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
|
||||||
|
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inserting a child into an existing void element fails the view', async () => {
|
||||||
|
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||||
|
const tree = createPluginRemoteTree();
|
||||||
|
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
|
||||||
|
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
|
||||||
|
assert.equal(tree.isFailed(), true);
|
||||||
|
assert.deepEqual(tree.snapshot(), { children: [] });
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { RemoteReceiver } from '@remote-dom/core/receivers';
|
||||||
|
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
|
||||||
|
|
||||||
|
type WireNode = { id: string; type: 3 | 8; data: string } | {
|
||||||
|
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
|
||||||
|
};
|
||||||
|
type WireTree = { children: WireNode[] };
|
||||||
|
|
||||||
|
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
|
||||||
|
export function createPluginRemoteTree() {
|
||||||
|
const receiver = new RemoteReceiver();
|
||||||
|
let current: WireTree = { children: [] };
|
||||||
|
let failed = false;
|
||||||
|
const project = (node: unknown): WireNode => {
|
||||||
|
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
|
||||||
|
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
|
||||||
|
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
apply(input: unknown): WireTree {
|
||||||
|
if (failed) throw new Error('Plugin view has been closed after an invalid update');
|
||||||
|
try {
|
||||||
|
validatePluginMutationBatch(input);
|
||||||
|
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
|
||||||
|
// defaults those to properties, so explicitly mark them as attributes.
|
||||||
|
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
|
||||||
|
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
|
||||||
|
const next = { children: receiver.root.children.map(project) };
|
||||||
|
validatePluginRemoteTree(next);
|
||||||
|
current = next;
|
||||||
|
return next;
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
current = { children: [] };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snapshot(): WireTree { return current; },
|
||||||
|
isFailed(): boolean { return failed; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,111 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
|
||||||
import {
|
import { PluginViewTabStore } from './pluginViewTabStore';
|
||||||
PluginViewLifecycleController,
|
|
||||||
consumeClosedPluginViewInstance,
|
|
||||||
reconcilePluginViewTabCatalog,
|
|
||||||
markPluginViewOpenTokensClosed,
|
|
||||||
reconcileClosedPluginView,
|
|
||||||
rememberClosedPluginViewInstance,
|
|
||||||
resolvePluginViewSnapshotSelection,
|
|
||||||
shouldReconcilePluginViewTabCatalog,
|
|
||||||
withdrawPluginViewTab,
|
|
||||||
type HostedPluginViewState,
|
|
||||||
} from './pluginViewLifecycle.ts';
|
|
||||||
import { PluginViewTabStore } from './pluginViewTabStore.ts';
|
|
||||||
|
|
||||||
function view(id: string, tabId?: string): HostedPluginViewState {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
viewId: `view.${id}`,
|
|
||||||
scopeId: 'window:main',
|
|
||||||
retainContextWhenHidden: false,
|
|
||||||
...(tabId ? { tabId } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('host close events clear the active renderer instance and identify its native tab', () => {
|
|
||||||
const current = view('active', 'plugin-view:publisher.plugin:view.active');
|
|
||||||
const result = reconcileClosedPluginView({
|
|
||||||
current,
|
|
||||||
retained: new Map([['retained', view('retained')]]),
|
|
||||||
instanceId: 'active',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.current, null);
|
|
||||||
assert.equal(result.matchedCurrent, true);
|
|
||||||
assert.equal(result.closedTabId, current.tabId);
|
|
||||||
assert.equal(result.retained.size, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('host close events remove retained instances without dismissing another active view', () => {
|
|
||||||
const current = view('active');
|
|
||||||
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
|
|
||||||
const result = reconcileClosedPluginView({
|
|
||||||
current,
|
|
||||||
retained: new Map([
|
|
||||||
['closed', retainedTab],
|
|
||||||
['kept', view('retained-kept')],
|
|
||||||
]),
|
|
||||||
instanceId: 'retained-closed',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.current, current);
|
|
||||||
assert.equal(result.matchedCurrent, false);
|
|
||||||
assert.equal(result.matchedRetained, true);
|
|
||||||
assert.deepEqual([...result.retained.keys()], ['kept']);
|
|
||||||
assert.equal(result.closedTabId, retainedTab.tabId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
|
|
||||||
const tabId = 'plugin-view:publisher.plugin:view.shared';
|
|
||||||
const result = withdrawPluginViewTab({
|
|
||||||
current: view('active', tabId),
|
|
||||||
retained: new Map([
|
|
||||||
['same-tab', view('retained-same', tabId)],
|
|
||||||
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
|
|
||||||
]),
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.current, null);
|
|
||||||
assert.equal(result.matchedCurrent, true);
|
|
||||||
assert.equal(result.matchedRetained, true);
|
|
||||||
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
|
|
||||||
assert.deepEqual([...result.retained.keys()], ['other-tab']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an early host close tombstone is consumed when the open response arrives later', () => {
|
|
||||||
const tombstones = new Set<string>();
|
|
||||||
rememberClosedPluginViewInstance(tombstones, 'instance-early');
|
|
||||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
|
|
||||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
|
|
||||||
|
|
||||||
for (let index = 0; index < 300; index += 1) {
|
|
||||||
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
|
|
||||||
}
|
|
||||||
assert.equal(tombstones.size, 256);
|
|
||||||
assert.equal(tombstones.has('instance-0'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('explicit close marks only in-flight opens owned by the closed surface', () => {
|
|
||||||
const first = Symbol('first');
|
|
||||||
const second = Symbol('second');
|
|
||||||
const explicitlyClosed = new Set<symbol>();
|
|
||||||
const opening = new Map<string, Set<symbol>>([
|
|
||||||
['window:main\0view.first', new Set([first])],
|
|
||||||
['window:main\0view.second', new Set([second])],
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(markPluginViewOpenTokensClosed(
|
|
||||||
opening,
|
|
||||||
explicitlyClosed,
|
|
||||||
'window:main\0view.first',
|
|
||||||
), 1);
|
|
||||||
assert.deepEqual([...explicitlyClosed], [first]);
|
|
||||||
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
||||||
const previous = {
|
const previous = {
|
||||||
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
|
|||||||
assert.equal(store.getTabs()[0]?.title, 'Localized View');
|
assert.equal(store.getTabs()[0]?.title, 'Localized View');
|
||||||
assert.equal(activeTabId, tab.id);
|
assert.equal(activeTabId, tab.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
|
|
||||||
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
|
|
||||||
const tabId = 'plugin-view:publisher.plugin:view.shared';
|
|
||||||
const active = view('active', tabId);
|
|
||||||
const retained = view('retained');
|
|
||||||
controller.setCurrent(active);
|
|
||||||
controller.retain('window:main\0view.retained', retained);
|
|
||||||
|
|
||||||
const token = controller.beginOpen({
|
|
||||||
viewKey: 'window:main\0view.active',
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
const closed = controller.handleTabClose(tabId);
|
|
||||||
assert.deepEqual(closed.instanceIds, [active.id]);
|
|
||||||
assert.equal(controller.shouldCloseOpen(token), true);
|
|
||||||
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
|
|
||||||
assert.equal(controller.shouldCloseOpen(token), false);
|
|
||||||
|
|
||||||
const early = controller.handleHostClose('instance-before-open-response');
|
|
||||||
assert.equal(early.matchedCurrent, false);
|
|
||||||
assert.equal(early.matchedRetained, false);
|
|
||||||
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
|
|
||||||
|
|
||||||
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
|
|
||||||
assert.equal(controller.getCurrent(), null);
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
export interface HostedPluginViewState {
|
|
||||||
id: string;
|
|
||||||
viewId: string;
|
|
||||||
scopeId: string;
|
|
||||||
retainContextWhenHidden: boolean;
|
|
||||||
tabId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginViewSnapshotSelection<T> {
|
export interface PluginViewSnapshotSelection<T> {
|
||||||
requestViewId: string;
|
requestViewId: string;
|
||||||
contextKey: string;
|
contextKey: string;
|
||||||
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
|
|||||||
store.refreshMetadata(entries);
|
store.refreshMetadata(entries);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
|
|
||||||
current,
|
|
||||||
retained,
|
|
||||||
instanceId,
|
|
||||||
}: {
|
|
||||||
current: T | null;
|
|
||||||
retained: ReadonlyMap<string, T>;
|
|
||||||
instanceId: string;
|
|
||||||
}): {
|
|
||||||
current: T | null;
|
|
||||||
retained: Map<string, T>;
|
|
||||||
matchedCurrent: boolean;
|
|
||||||
matchedRetained: boolean;
|
|
||||||
closedTabId?: string;
|
|
||||||
} {
|
|
||||||
const matchedCurrent = current?.id === instanceId;
|
|
||||||
let matchedRetained = false;
|
|
||||||
let retainedTabId: string | undefined;
|
|
||||||
const nextRetained = new Map<string, T>();
|
|
||||||
for (const [key, view] of retained) {
|
|
||||||
if (view.id === instanceId) {
|
|
||||||
matchedRetained = true;
|
|
||||||
retainedTabId = view.tabId;
|
|
||||||
}
|
|
||||||
else nextRetained.set(key, view);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
current: matchedCurrent ? null : current,
|
|
||||||
retained: nextRetained,
|
|
||||||
matchedCurrent,
|
|
||||||
matchedRetained,
|
|
||||||
...((matchedCurrent ? current?.tabId : retainedTabId)
|
|
||||||
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
|
|
||||||
current,
|
|
||||||
retained,
|
|
||||||
tabId,
|
|
||||||
}: {
|
|
||||||
current: T | null;
|
|
||||||
retained: ReadonlyMap<string, T>;
|
|
||||||
tabId: string;
|
|
||||||
}): {
|
|
||||||
current: T | null;
|
|
||||||
retained: Map<string, T>;
|
|
||||||
instanceIds: string[];
|
|
||||||
matchedCurrent: boolean;
|
|
||||||
matchedRetained: boolean;
|
|
||||||
} {
|
|
||||||
const matchedCurrent = current?.tabId === tabId;
|
|
||||||
let matchedRetained = false;
|
|
||||||
const instanceIds: string[] = [];
|
|
||||||
if (matchedCurrent && current) instanceIds.push(current.id);
|
|
||||||
const nextRetained = new Map<string, T>();
|
|
||||||
for (const [key, view] of retained) {
|
|
||||||
if (view.tabId === tabId) {
|
|
||||||
matchedRetained = true;
|
|
||||||
instanceIds.push(view.id);
|
|
||||||
} else {
|
|
||||||
nextRetained.set(key, view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
current: matchedCurrent ? null : current,
|
|
||||||
retained: nextRetained,
|
|
||||||
instanceIds,
|
|
||||||
matchedCurrent,
|
|
||||||
matchedRetained,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberClosedPluginViewInstance(
|
|
||||||
tombstones: Set<string>,
|
|
||||||
instanceId: string,
|
|
||||||
limit = 256,
|
|
||||||
): void {
|
|
||||||
if (tombstones.size >= limit) {
|
|
||||||
const oldest = tombstones.values().next().value;
|
|
||||||
if (typeof oldest === 'string') tombstones.delete(oldest);
|
|
||||||
}
|
|
||||||
tombstones.add(instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
|
|
||||||
return tombstones.delete(instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markPluginViewOpenTokensClosed(
|
|
||||||
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
|
|
||||||
explicitlyClosedTokens: Set<symbol>,
|
|
||||||
ownerKey: string | null | undefined,
|
|
||||||
): number {
|
|
||||||
if (!ownerKey) return 0;
|
|
||||||
const tokens = openingTokens.get(ownerKey);
|
|
||||||
if (!tokens) return 0;
|
|
||||||
for (const token of tokens) explicitlyClosedTokens.add(token);
|
|
||||||
return tokens.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
|
|
||||||
private current: T | null = null;
|
|
||||||
private retained = new Map<string, T>();
|
|
||||||
private readonly closedInstanceIds = new Set<string>();
|
|
||||||
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
|
|
||||||
private readonly openingTokensByView = new Map<string, Set<symbol>>();
|
|
||||||
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
|
|
||||||
|
|
||||||
getCurrent(): T | null {
|
|
||||||
return this.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrent(view: T | null): void {
|
|
||||||
this.current = view;
|
|
||||||
}
|
|
||||||
|
|
||||||
takeCurrent(): T | null {
|
|
||||||
const current = this.current;
|
|
||||||
this.current = null;
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
retain(key: string, view: T): void {
|
|
||||||
this.retained.set(key, view);
|
|
||||||
}
|
|
||||||
|
|
||||||
takeRetained(key: string): T | null {
|
|
||||||
const retained = this.retained.get(key) ?? null;
|
|
||||||
if (retained) this.retained.delete(key);
|
|
||||||
return retained;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRetained(key: string): void {
|
|
||||||
this.retained.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
|
|
||||||
const removed: T[] = [];
|
|
||||||
for (const [key, view] of this.retained) {
|
|
||||||
if (!predicate(view)) continue;
|
|
||||||
this.retained.delete(key);
|
|
||||||
removed.push(view);
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHostClose(instanceId: string) {
|
|
||||||
const next = reconcileClosedPluginView({
|
|
||||||
current: this.current,
|
|
||||||
retained: this.retained,
|
|
||||||
instanceId,
|
|
||||||
});
|
|
||||||
this.current = next.current;
|
|
||||||
this.retained = next.retained;
|
|
||||||
if (!next.matchedCurrent && !next.matchedRetained) {
|
|
||||||
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTabClose(tabId: string) {
|
|
||||||
const next = withdrawPluginViewTab({
|
|
||||||
current: this.current,
|
|
||||||
retained: this.retained,
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
this.current = next.current;
|
|
||||||
this.retained = next.retained;
|
|
||||||
markPluginViewOpenTokensClosed(
|
|
||||||
this.openingTokensByTab,
|
|
||||||
this.explicitlyClosedOpenTokens,
|
|
||||||
tabId,
|
|
||||||
);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
markViewClosed(viewKey: string | null | undefined): number {
|
|
||||||
return markPluginViewOpenTokensClosed(
|
|
||||||
this.openingTokensByView,
|
|
||||||
this.explicitlyClosedOpenTokens,
|
|
||||||
viewKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
beginOpen({
|
|
||||||
viewKey,
|
|
||||||
tabId,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
viewKey: string;
|
|
||||||
tabId?: string;
|
|
||||||
label?: string;
|
|
||||||
}): symbol {
|
|
||||||
const token = Symbol(label ?? tabId ?? viewKey);
|
|
||||||
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
|
|
||||||
viewTokens.add(token);
|
|
||||||
this.openingTokensByView.set(viewKey, viewTokens);
|
|
||||||
if (tabId) {
|
|
||||||
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
|
|
||||||
tabTokens.add(token);
|
|
||||||
this.openingTokensByTab.set(tabId, tabTokens);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
|
|
||||||
this.explicitlyClosedOpenTokens.delete(token);
|
|
||||||
const viewTokens = this.openingTokensByView.get(viewKey);
|
|
||||||
viewTokens?.delete(token);
|
|
||||||
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
|
|
||||||
if (!tabId) return;
|
|
||||||
const tabTokens = this.openingTokensByTab.get(tabId);
|
|
||||||
tabTokens?.delete(token);
|
|
||||||
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldCloseOpen(token: symbol): boolean {
|
|
||||||
return this.explicitlyClosedOpenTokens.has(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
consumeHostClose(instanceId: string): boolean {
|
|
||||||
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
drain(): T[] {
|
|
||||||
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
|
|
||||||
this.current = null;
|
|
||||||
this.retained.clear();
|
|
||||||
this.closedInstanceIds.clear();
|
|
||||||
this.openingTokensByTab.clear();
|
|
||||||
this.openingTokensByView.clear();
|
|
||||||
this.explicitlyClosedOpenTokens.clear();
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
|
||||||
|
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
|
||||||
|
import { PluginViewSessions } from './pluginViewSession';
|
||||||
|
function fixture() {
|
||||||
|
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
|
||||||
|
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
|
||||||
|
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
|
||||||
|
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
|
||||||
|
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
|
||||||
|
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
|
||||||
|
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
|
||||||
|
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
|
||||||
|
}
|
||||||
|
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
|
||||||
|
test('a hidden view resumes its existing state and plugin instance', async () => {
|
||||||
|
const f = fixture(); const store = new PluginViewSessions();
|
||||||
|
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
|
||||||
|
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
|
||||||
|
first.release(); const next = store.acquire('view', f.bridge, request);
|
||||||
|
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
|
||||||
|
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
|
||||||
|
});
|
||||||
|
test('host invalidation clears retained content and a reopen creates a new instance', () => {
|
||||||
|
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
|
||||||
|
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
|
||||||
|
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
|
||||||
|
});
|
||||||
|
test('unacknowledged input is not overwritten by an older plugin update', () => {
|
||||||
|
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
|
||||||
|
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
|
||||||
|
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
|
||||||
|
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
|
||||||
|
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
|
||||||
|
});
|
||||||
|
test('idle instances are evicted while active instances are never silently replaced', () => {
|
||||||
|
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
|
||||||
|
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
|
||||||
|
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { createPluginRemoteTree } from './pluginRemoteTree';
|
||||||
|
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
|
||||||
|
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
|
||||||
|
|
||||||
|
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
|
||||||
|
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
|
||||||
|
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
|
||||||
|
|
||||||
|
/** Owns a window's bounded, retained UI instances independently of React mounts. */
|
||||||
|
export class PluginViewSessions {
|
||||||
|
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
|
||||||
|
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
|
||||||
|
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const tree = createPluginRemoteTree();
|
||||||
|
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
|
||||||
|
const cleanup: Array<() => void> = [];
|
||||||
|
const dispose = (failed = false) => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
|
||||||
|
this.sessions.delete(key);
|
||||||
|
publish({ ...empty(), loading: false, error: failed });
|
||||||
|
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
|
||||||
|
};
|
||||||
|
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
|
||||||
|
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
|
||||||
|
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
|
||||||
|
try {
|
||||||
|
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
|
||||||
|
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
|
||||||
|
} catch { dispose(true); }
|
||||||
|
}));
|
||||||
|
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
|
||||||
|
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
|
||||||
|
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
|
||||||
|
}).catch(() => dispose(true));
|
||||||
|
return {
|
||||||
|
viewId: request.viewId,
|
||||||
|
scope: request.scopeId,
|
||||||
|
image: async (path: string) => {
|
||||||
|
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
|
||||||
|
const result = await bridge.getPluginViewResource({ instanceId: id, path });
|
||||||
|
if (disposed) throw new Error('Plugin view closed');
|
||||||
|
return result.url;
|
||||||
|
},
|
||||||
|
get idle() { return users === 0; },
|
||||||
|
getSnapshot: () => state,
|
||||||
|
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||||
|
acquire: () => { clearTimeout(timer); users++; },
|
||||||
|
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
|
||||||
|
send: (event: HostDrawnViewEvent) => {
|
||||||
|
if (disposed || users === 0) return;
|
||||||
|
publish({ ...state, settled: false });
|
||||||
|
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
|
||||||
|
},
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
|
||||||
|
let session = this.sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
while (this.sessions.size >= this.capacity) {
|
||||||
|
const candidate = [...this.sessions.values()].find((item) => item.idle);
|
||||||
|
if (!candidate) throw new Error('Too many open plugin views');
|
||||||
|
candidate.dispose();
|
||||||
|
}
|
||||||
|
session = this.create(key, bridge, request); this.sessions.set(key, session);
|
||||||
|
}
|
||||||
|
session.acquire(); return session;
|
||||||
|
}
|
||||||
|
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
|
||||||
|
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
|
||||||
|
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
|
||||||
|
}
|
||||||
|
export const pluginViewSessions = new PluginViewSessions();
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
|
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||||
STORAGE_KEY_WINDOW_OPACITY,
|
STORAGE_KEY_WINDOW_OPACITY,
|
||||||
|
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||||
STORAGE_KEY_APP_ICON_VARIANT,
|
STORAGE_KEY_APP_ICON_VARIANT,
|
||||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||||
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
|
|||||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||||
setWindowOpacity: (raw: unknown) => void;
|
setWindowOpacity: (raw: unknown) => void;
|
||||||
|
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||||
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
|
|||||||
setIsHotkeyRecordingState,
|
setIsHotkeyRecordingState,
|
||||||
setGlobalHotkeyEnabled,
|
setGlobalHotkeyEnabled,
|
||||||
setWindowOpacity,
|
setWindowOpacity,
|
||||||
|
setWindowBackgroundBlur,
|
||||||
setAppIconVariant,
|
setAppIconVariant,
|
||||||
setAutoUpdateEnabled,
|
setAutoUpdateEnabled,
|
||||||
setAutoLaunchEnabled,
|
setAutoLaunchEnabled,
|
||||||
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
|
|||||||
if (key === STORAGE_KEY_WINDOW_OPACITY) {
|
if (key === STORAGE_KEY_WINDOW_OPACITY) {
|
||||||
setWindowOpacity(value);
|
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) {
|
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
|
||||||
const nextVariant = resolveAppIconVariant(value);
|
const nextVariant = resolveAppIconVariant(value);
|
||||||
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
|
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
|
||||||
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
|
|||||||
setFollowAppTerminalThemeState,
|
setFollowAppTerminalThemeState,
|
||||||
setGlobalHotkeyEnabled,
|
setGlobalHotkeyEnabled,
|
||||||
setWindowOpacity,
|
setWindowOpacity,
|
||||||
|
setWindowBackgroundBlur,
|
||||||
setAppIconVariant,
|
setAppIconVariant,
|
||||||
setHotkeyScheme,
|
setHotkeyScheme,
|
||||||
setIsHotkeyRecordingState,
|
setIsHotkeyRecordingState,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
STORAGE_KEY_UI_LANGUAGE,
|
STORAGE_KEY_UI_LANGUAGE,
|
||||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||||
STORAGE_KEY_WINDOW_OPACITY,
|
STORAGE_KEY_WINDOW_OPACITY,
|
||||||
|
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||||
STORAGE_KEY_APP_ICON_VARIANT,
|
STORAGE_KEY_APP_ICON_VARIANT,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||||
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
globalHotkeyEnabled: boolean;
|
globalHotkeyEnabled: boolean;
|
||||||
autoUpdateEnabled: boolean;
|
autoUpdateEnabled: boolean;
|
||||||
windowOpacity: number;
|
windowOpacity: number;
|
||||||
|
windowBackgroundBlur: boolean;
|
||||||
appIconVariant: AppIconVariant;
|
appIconVariant: AppIconVariant;
|
||||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||||
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||||
setWindowOpacity: (raw: unknown) => void;
|
setWindowOpacity: (raw: unknown) => void;
|
||||||
|
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||||
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
|
|||||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||||
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
|
|||||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||||
}: UseSettingsStorageSyncParams) {
|
}: UseSettingsStorageSyncParams) {
|
||||||
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
|
|||||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||||
});
|
});
|
||||||
settingsSnapshotRef.current = {
|
settingsSnapshotRef.current = {
|
||||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||||
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
|
|||||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
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)
|
// 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) {
|
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||||
setWindowOpacity(e.newValue);
|
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) {
|
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
|
||||||
const newValue = resolveAppIconVariant(e.newValue);
|
const newValue = resolveAppIconVariant(e.newValue);
|
||||||
if (newValue !== s.appIconVariant) {
|
if (newValue !== s.appIconVariant) {
|
||||||
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
|
|||||||
setGlobalHotkeyEnabled,
|
setGlobalHotkeyEnabled,
|
||||||
setHostClickBehaviorState,
|
setHostClickBehaviorState,
|
||||||
setWindowOpacity,
|
setWindowOpacity,
|
||||||
|
setWindowBackgroundBlur,
|
||||||
setAppIconVariant,
|
setAppIconVariant,
|
||||||
setHotkeyScheme,
|
setHotkeyScheme,
|
||||||
setLightUiThemeId,
|
setLightUiThemeId,
|
||||||
|
|||||||
@@ -393,12 +393,10 @@ export function useSftpFollowTerminalCwd({
|
|||||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeSessionId,
|
|
||||||
activeTerminalCwd,
|
activeTerminalCwd,
|
||||||
activeTerminalCwdTrusted,
|
activeTerminalCwdTrusted,
|
||||||
canFollowTerminalCwd,
|
canFollowTerminalCwd,
|
||||||
effectiveFollowTerminalCwd,
|
effectiveFollowTerminalCwd,
|
||||||
focusedSessionId,
|
|
||||||
hasActiveWork,
|
hasActiveWork,
|
||||||
isVisible,
|
isVisible,
|
||||||
onGetTerminalCwd,
|
onGetTerminalCwd,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||||
STORAGE_KEY_WINDOW_OPACITY,
|
STORAGE_KEY_WINDOW_OPACITY,
|
||||||
|
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||||
STORAGE_KEY_APP_ICON_VARIANT,
|
STORAGE_KEY_APP_ICON_VARIANT,
|
||||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
} from '../../domain/httpNetworkProxy';
|
} from '../../domain/httpNetworkProxy';
|
||||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||||
|
import { isMacPlatform } from '../../lib/utils';
|
||||||
|
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
|
||||||
import {
|
import {
|
||||||
parseWindowOpacityRecord,
|
parseWindowOpacityRecord,
|
||||||
serializeWindowOpacityRecord,
|
serializeWindowOpacityRecord,
|
||||||
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
|
|||||||
autoLaunchEnabled: boolean;
|
autoLaunchEnabled: boolean;
|
||||||
windowOpacityRecord: WindowOpacityRecord;
|
windowOpacityRecord: WindowOpacityRecord;
|
||||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||||
|
windowBackgroundBlur: boolean;
|
||||||
appIconVariant: AppIconVariant;
|
appIconVariant: AppIconVariant;
|
||||||
autoUpdateEnabled: boolean;
|
autoUpdateEnabled: boolean;
|
||||||
httpNetworkProxy: HttpNetworkProxySettings;
|
httpNetworkProxy: HttpNetworkProxySettings;
|
||||||
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
|
|||||||
autoLaunchEnabled,
|
autoLaunchEnabled,
|
||||||
windowOpacityRecord,
|
windowOpacityRecord,
|
||||||
windowOpacityMutationSourceRef,
|
windowOpacityMutationSourceRef,
|
||||||
|
windowBackgroundBlur,
|
||||||
appIconVariant,
|
appIconVariant,
|
||||||
autoUpdateEnabled,
|
autoUpdateEnabled,
|
||||||
httpNetworkProxy,
|
httpNetworkProxy,
|
||||||
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
|
|||||||
persistMountedRef,
|
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
|
// Persist and sync app icon variant
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -30,3 +30,20 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
assert.ok(versions.length >= 2);
|
assert.ok(versions.length >= 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
|
||||||
|
const a = 'revision-a'; const b = 'revision-b';
|
||||||
|
terminalCwdStore.setCwd(a, '/same', 'osc7');
|
||||||
|
const revisionA = terminalCwdStore.getSessionRevision(a);
|
||||||
|
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
|
||||||
|
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
|
||||||
|
const globalVersion = terminalCwdStore.getVersion();
|
||||||
|
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
|
||||||
|
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
|
||||||
|
assert.equal(terminalCwdStore.getVersion(), globalVersion);
|
||||||
|
const revisionB = terminalCwdStore.getSessionRevision(b);
|
||||||
|
terminalCwdStore.prune(new Set([b]));
|
||||||
|
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
|
||||||
|
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
|
||||||
|
terminalCwdStore.prune(new Set());
|
||||||
|
});
|
||||||
@@ -10,11 +10,16 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
|
|||||||
class TerminalCwdStore {
|
class TerminalCwdStore {
|
||||||
private cwdBySession = new Map<string, string>();
|
private cwdBySession = new Map<string, string>();
|
||||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||||
|
private revisionBySession = new Map<string, number>();
|
||||||
|
private revision = 0;
|
||||||
private version = 0;
|
private version = 0;
|
||||||
private listeners = new Set<Listener>();
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
getVersion = (): number => this.version;
|
getVersion = (): number => this.version;
|
||||||
|
|
||||||
|
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
|
||||||
|
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
|
||||||
|
|
||||||
getCwd = (sessionId: string | null | undefined): string | null => {
|
getCwd = (sessionId: string | null | undefined): string | null => {
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
return this.cwdBySession.get(sessionId) ?? null;
|
return this.cwdBySession.get(sessionId) ?? null;
|
||||||
@@ -39,7 +44,10 @@ class TerminalCwdStore {
|
|||||||
const nextSource = next
|
const nextSource = next
|
||||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||||
: undefined;
|
: undefined;
|
||||||
if (current === next && currentSource === nextSource) return false;
|
if (current === next && currentSource === nextSource) {
|
||||||
|
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (next) {
|
if (next) {
|
||||||
this.cwdBySession.set(sessionId, next);
|
this.cwdBySession.set(sessionId, next);
|
||||||
@@ -48,6 +56,7 @@ class TerminalCwdStore {
|
|||||||
this.cwdBySession.delete(sessionId);
|
this.cwdBySession.delete(sessionId);
|
||||||
this.sourceBySession.delete(sessionId);
|
this.sourceBySession.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
this.revisionBySession.set(sessionId, ++this.revision);
|
||||||
this.version += 1;
|
this.version += 1;
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener();
|
listener();
|
||||||
@@ -61,9 +70,13 @@ class TerminalCwdStore {
|
|||||||
if (!validSessionIds.has(sessionId)) {
|
if (!validSessionIds.has(sessionId)) {
|
||||||
this.cwdBySession.delete(sessionId);
|
this.cwdBySession.delete(sessionId);
|
||||||
this.sourceBySession.delete(sessionId);
|
this.sourceBySession.delete(sessionId);
|
||||||
|
this.revisionBySession.delete(sessionId);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const id of this.revisionBySession.keys()) {
|
||||||
|
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
|
||||||
|
}
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
this.version += 1;
|
this.version += 1;
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||||
|
import { terminalCwdStore } from './terminalCwdStore';
|
||||||
|
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||||
|
|
||||||
|
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
|
||||||
|
let cwd = '/old'; let ready = true; let current = true;
|
||||||
|
const writes: string[] = [];
|
||||||
|
const published: string[] = [];
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: 'test-directory', confirmationMs: 10,
|
||||||
|
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
|
||||||
|
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
|
||||||
|
isCurrent: () => current, isReady: () => ready,
|
||||||
|
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
|
||||||
|
});
|
||||||
|
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
|
||||||
|
const ctx = setup(); const accepted: string[] = [];
|
||||||
|
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
|
||||||
|
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
|
||||||
|
assert.deepEqual(accepted, ['accepted']);
|
||||||
|
assert.deepEqual(ctx.published, ['/next']);
|
||||||
|
ctx.controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('busy, control-character and stale sessions never receive bytes', async () => {
|
||||||
|
const ctx = setup(); ctx.busy();
|
||||||
|
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
|
||||||
|
const invalid = setup();
|
||||||
|
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
|
||||||
|
const stale = setup(); stale.stale();
|
||||||
|
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
|
||||||
|
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
|
||||||
|
let finish!: (path: string) => void;
|
||||||
|
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
|
||||||
|
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
|
||||||
|
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
|
||||||
|
ctx.busy(); finish('/next');
|
||||||
|
await assert.rejects(first, { code: 'busy' });
|
||||||
|
assert.deepEqual(ctx.writes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
|
||||||
|
let finish!: (cwd: string) => void;
|
||||||
|
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
|
||||||
|
const read = ctx.controller.refresh();
|
||||||
|
ctx.controller.dispose(); finish('/stale');
|
||||||
|
await assert.rejects(read, { code: 'unavailable' });
|
||||||
|
assert.deepEqual(ctx.published, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed cd keeps the observed cwd and never retries the write', async () => {
|
||||||
|
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
|
||||||
|
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
|
||||||
|
assert.ok(ctx.published.every(value => value === '/old'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directory symlinks confirm against physical cwd', async () => {
|
||||||
|
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
|
||||||
|
await ctx.controller.change('/link', new AbortController().signal, () => {});
|
||||||
|
assert.equal(ctx.published.at(-1), '/real');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
|
||||||
|
let finish!: (cwd: string) => void;
|
||||||
|
let reads = 0;
|
||||||
|
const ctx = setup({ cwd: () => {
|
||||||
|
reads++;
|
||||||
|
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
|
||||||
|
} });
|
||||||
|
const oldRead = ctx.controller.refresh();
|
||||||
|
const queued = ctx.controller.refresh();
|
||||||
|
finish('/before-cd');
|
||||||
|
await Promise.all([oldRead, queued]);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(reads, 2);
|
||||||
|
assert.equal(ctx.published.at(-1), '/after-cd');
|
||||||
|
ctx.controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
|
||||||
|
const id = 'strict-after-other-session';
|
||||||
|
let finish!: (cwd: string) => void;
|
||||||
|
const published: string[] = [];
|
||||||
|
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||||
|
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
const pending = controller.refresh();
|
||||||
|
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
|
||||||
|
finish('/actual-after-cd');
|
||||||
|
assert.equal(await pending, '/actual-after-cd');
|
||||||
|
assert.deepEqual(published, ['/actual-after-cd']);
|
||||||
|
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
|
||||||
|
const id = 'pre-write-osc7-after-other-session';
|
||||||
|
let finish!: (cwd: string) => void;
|
||||||
|
const published: string[] = [];
|
||||||
|
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||||
|
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
const changing = controller.change('/after', new AbortController().signal, () => {});
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
|
||||||
|
finish('/after');
|
||||||
|
await changing;
|
||||||
|
assert.deepEqual(published, ['/after']);
|
||||||
|
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const nextCwd of ['/new-osc7', '/original']) {
|
||||||
|
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
|
||||||
|
const id = 'same-session-osc7-' + nextCwd;
|
||||||
|
let finish!: (cwd: string) => void;
|
||||||
|
const published: string[] = [];
|
||||||
|
terminalCwdStore.setCwd(id, '/original', 'stale');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||||
|
publish: value => { published.push(value); },
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
const pending = controller.refresh();
|
||||||
|
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
|
||||||
|
finish('/older-backend-result');
|
||||||
|
assert.equal(await pending, nextCwd);
|
||||||
|
assert.deepEqual(published, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const probe of ['fails', 'stalls'] as const) {
|
||||||
|
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
|
||||||
|
const id = 'osc7-confirm-' + probe;
|
||||||
|
const writes: string[] = [];
|
||||||
|
terminalCwdStore.setCwd(id, '/old', 'osc7');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: {
|
||||||
|
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||||
|
write: data => {
|
||||||
|
writes.push(data);
|
||||||
|
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
await controller.change('/next', new AbortController().signal, () => {});
|
||||||
|
assert.deepEqual(writes, ["cd -- '/next'\r"]);
|
||||||
|
assert.equal(controller.hasConfirmedCwd(), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
|
||||||
|
const id = 'probe-after-pre-write-osc7';
|
||||||
|
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
|
||||||
|
realpath: async value => value, write() {} },
|
||||||
|
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
await controller.change('/after', new AbortController().signal, () => {});
|
||||||
|
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||||
|
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
|
||||||
|
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
|
||||||
|
const id = 'osc7-revision-confirm-' + report;
|
||||||
|
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: {
|
||||||
|
cwd: async () => { throw new Error('no process cwd support'); },
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||||
|
write() {
|
||||||
|
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
|
||||||
|
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
|
||||||
|
));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publish() {},
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
const change = controller.change('/next', new AbortController().signal, () => {});
|
||||||
|
if (report === 'fresh-unchanged') await change;
|
||||||
|
else await assert.rejects(change, { code: 'changeFailed' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cancellation of ['abort', 'dispose'] as const) {
|
||||||
|
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
|
||||||
|
const id = 'osc7-cancel-' + cancellation;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
|
||||||
|
realpath: async value => value,
|
||||||
|
write() { setImmediate(() => {
|
||||||
|
if (cancellation === 'abort') abort.abort(); else controller.dispose();
|
||||||
|
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||||
|
}); } },
|
||||||
|
publish() {},
|
||||||
|
});
|
||||||
|
t.after(() => controller.dispose());
|
||||||
|
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
|
||||||
|
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
|
||||||
|
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||||
|
import { terminalCwdStore } from './terminalCwdStore';
|
||||||
|
|
||||||
|
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
|
||||||
|
|
||||||
|
/** One registration represents one connected interactive shell generation. */
|
||||||
|
export function createTerminalDirectoryController(options: {
|
||||||
|
sessionId: string;
|
||||||
|
io: TerminalDirectoryIO;
|
||||||
|
isCurrent: () => boolean;
|
||||||
|
isReady: () => boolean;
|
||||||
|
publish: (cwd: string) => void;
|
||||||
|
confirmationMs?: number;
|
||||||
|
}) {
|
||||||
|
let disposed = false;
|
||||||
|
let changing = false;
|
||||||
|
let confirmed = false;
|
||||||
|
let refreshing: Promise<string> | null = null;
|
||||||
|
let refreshAgain = false;
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
const current = () => !disposed && options.isCurrent();
|
||||||
|
function check(signal?: AbortSignal) {
|
||||||
|
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
|
||||||
|
}
|
||||||
|
function ready() {
|
||||||
|
check();
|
||||||
|
if (!options.isReady()) throw new DirectoryPickerError('busy');
|
||||||
|
}
|
||||||
|
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const finish = (error?: unknown, value?: T) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
lifetime.signal.removeEventListener('abort', cancel);
|
||||||
|
signal?.removeEventListener('abort', cancel);
|
||||||
|
if (error) reject(error); else resolve(value!);
|
||||||
|
};
|
||||||
|
const cancel = () => finish(new DirectoryPickerError('unavailable'));
|
||||||
|
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
|
||||||
|
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
||||||
|
signal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
if (signal?.aborted || lifetime.signal.aborted) cancel();
|
||||||
|
task.then(value => finish(undefined, value), error => finish(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function refresh(submittedRevision?: number): Promise<string> {
|
||||||
|
check();
|
||||||
|
const activeShellCwd = () => {
|
||||||
|
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||||
|
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||||
|
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
|
||||||
|
? cwd : null;
|
||||||
|
};
|
||||||
|
// A process probe may read the outer PTY shell while a nested shell is
|
||||||
|
// active. OSC 7 remains authoritative across debounce delays, completion
|
||||||
|
// events and picker remounts. Only a cd submission may probe past its own
|
||||||
|
// pre-write report while waiting for fresh confirmation.
|
||||||
|
const reported = activeShellCwd();
|
||||||
|
if (reported) { confirmed = true; return reported; }
|
||||||
|
if (refreshing) {
|
||||||
|
// Output may settle while a pre-cd query is still in flight. Keep one
|
||||||
|
// follow-up query so that its older result cannot be the last update.
|
||||||
|
refreshAgain = true;
|
||||||
|
return refreshing;
|
||||||
|
}
|
||||||
|
const pending = (async () => {
|
||||||
|
const cwd = await bounded(options.io.cwd());
|
||||||
|
check();
|
||||||
|
const reported = activeShellCwd();
|
||||||
|
const live = terminalCwdStore.getCwd(options.sessionId);
|
||||||
|
confirmed = true;
|
||||||
|
if (reported) return reported;
|
||||||
|
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
|
||||||
|
return cwd;
|
||||||
|
})();
|
||||||
|
refreshing = pending;
|
||||||
|
try { return await pending; } finally {
|
||||||
|
if (refreshing === pending) refreshing = null;
|
||||||
|
if (refreshAgain && current()) {
|
||||||
|
refreshAgain = false;
|
||||||
|
void refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
|
||||||
|
ready();
|
||||||
|
if (changing) throw new DirectoryPickerError('busy');
|
||||||
|
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
|
||||||
|
if (!intent) throw new DirectoryPickerError('unsupportedPath');
|
||||||
|
changing = true;
|
||||||
|
let confirmationFinished = false;
|
||||||
|
try {
|
||||||
|
const canonical = await bounded(options.io.realpath(path, signal), signal);
|
||||||
|
check(signal);
|
||||||
|
ready(); // The shell can become busy while resolving a remote symlink.
|
||||||
|
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
|
||||||
|
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
|
||||||
|
options.io.write(intent.command + '\r');
|
||||||
|
accepted();
|
||||||
|
const deadline = Date.now() + (options.confirmationMs ?? 8000);
|
||||||
|
const hasFreshOsc7 = () => {
|
||||||
|
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||||
|
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
|
||||||
|
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||||
|
&& (cwd === path || cwd === canonical);
|
||||||
|
};
|
||||||
|
const pause = () => new Promise<void>(resolve => {
|
||||||
|
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
|
||||||
|
const timer = setTimeout(done, 200);
|
||||||
|
lifetime.signal.addEventListener('abort', done, { once: true });
|
||||||
|
});
|
||||||
|
const confirmFromProbe = async () => {
|
||||||
|
while (!confirmationFinished && Date.now() < deadline) {
|
||||||
|
check(signal);
|
||||||
|
if (hasFreshOsc7()) return;
|
||||||
|
if (options.isReady()) {
|
||||||
|
try {
|
||||||
|
const cwd = await refresh(submittedRevision);
|
||||||
|
check(signal);
|
||||||
|
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!current() || signal.aborted) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pause();
|
||||||
|
}
|
||||||
|
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
|
||||||
|
};
|
||||||
|
// OSC 7 is authoritative even when process cwd lookup is unavailable or
|
||||||
|
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
|
||||||
|
const confirmFromOsc7 = async () => {
|
||||||
|
while (!confirmationFinished) {
|
||||||
|
check(signal);
|
||||||
|
if (hasFreshOsc7()) return;
|
||||||
|
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
|
||||||
|
await pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
|
||||||
|
check(signal);
|
||||||
|
confirmed = true;
|
||||||
|
} finally { confirmationFinished = true; changing = false; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
refresh: () => refresh(),
|
||||||
|
hasConfirmedCwd: () => confirmed,
|
||||||
|
async home() {
|
||||||
|
check();
|
||||||
|
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
|
||||||
|
check(); return result;
|
||||||
|
},
|
||||||
|
async list(path: string, signal: AbortSignal) {
|
||||||
|
check(signal);
|
||||||
|
const result = await bounded(options.io.list(path, signal), signal);
|
||||||
|
check(signal);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
change,
|
||||||
|
dispose() { disposed = true; lifetime.abort(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||||
|
import { terminalCwdStore } from './terminalCwdStore';
|
||||||
|
import {
|
||||||
|
observeTerminalDirectory, registerTerminalDirectory,
|
||||||
|
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
|
||||||
|
} from './terminalDirectoryRegistry';
|
||||||
|
|
||||||
|
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let cwd = '/before'; let reads = 0;
|
||||||
|
const published: string[] = [];
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
|
||||||
|
io: { cwd: async () => { reads++; return cwd; },
|
||||||
|
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||||
|
publish: value => { published.push(value); },
|
||||||
|
});
|
||||||
|
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
|
||||||
|
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
|
||||||
|
t.after(() => { stopObserving(); unregister(); });
|
||||||
|
cwd = '/after-manual-cd';
|
||||||
|
// Multiple prompt/output chunks produce just one read. No trusted prompt
|
||||||
|
// detection, command-completed callback, or parsed `cd` is involved.
|
||||||
|
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||||
|
t.mock.timers.tick(80);
|
||||||
|
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||||
|
t.mock.timers.tick(149);
|
||||||
|
assert.equal(reads, 0);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(published, ['/after-manual-cd']);
|
||||||
|
assert.equal(reads, 1);
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal(reads, 1, 'no polling after output has settled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const id = 'nested-shell-output';
|
||||||
|
const published: string[] = [];
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||||
|
// The PTY process stays in its original directory while a child shell
|
||||||
|
// runs in /. Its cwd is only a fallback for shells without OSC 7.
|
||||||
|
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
|
||||||
|
realpath: async value => value, write() {} },
|
||||||
|
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||||
|
});
|
||||||
|
const unregister = registerTerminalDirectory(id, controller);
|
||||||
|
const stopObserving = observeTerminalDirectory(id);
|
||||||
|
t.after(() => { stopObserving(); unregister(); });
|
||||||
|
scheduleObservedTerminalDirectoryRefresh(id);
|
||||||
|
terminalCwdStore.setCwd(id, '/', 'osc7');
|
||||||
|
t.mock.timers.tick(150);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(terminalCwdStore.getCwd(id), '/');
|
||||||
|
// Later output chunks, prompt completion and reopening/manual refresh must
|
||||||
|
// not treat that same active-shell report as stale merely due to timing.
|
||||||
|
scheduleObservedTerminalDirectoryRefresh(id);
|
||||||
|
t.mock.timers.tick(150);
|
||||||
|
refreshObservedTerminalDirectory(id);
|
||||||
|
assert.equal(await controller.refresh(), '/');
|
||||||
|
assert.deepEqual(published, []);
|
||||||
|
assert.equal(terminalCwdStore.getSource(id), 'osc7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let reads = 0;
|
||||||
|
const controller = createTerminalDirectoryController({
|
||||||
|
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
|
||||||
|
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
|
||||||
|
realpath: async value => value, write() {} }, publish() {},
|
||||||
|
});
|
||||||
|
const unregister = registerTerminalDirectory('hidden-chips', controller);
|
||||||
|
t.after(unregister);
|
||||||
|
const stopObserving = observeTerminalDirectory('hidden-chips');
|
||||||
|
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||||
|
stopObserving();
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(reads, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { TerminalDirectoryController } from './terminalDirectoryController';
|
||||||
|
|
||||||
|
const sessions = new Map<string, TerminalDirectoryController>();
|
||||||
|
const consumers = new Map<string, number>();
|
||||||
|
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const OUTPUT_SETTLE_MS = 150;
|
||||||
|
|
||||||
|
function cancelScheduledRefresh(id: string) {
|
||||||
|
const timer = scheduledRefreshes.get(id);
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
scheduledRefreshes.delete(id);
|
||||||
|
}
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export const subscribeTerminalDirectories = (listener: () => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
};
|
||||||
|
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
|
||||||
|
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
|
||||||
|
cancelScheduledRefresh(id);
|
||||||
|
sessions.set(id, controller);
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
return () => {
|
||||||
|
controller.dispose();
|
||||||
|
if (sessions.get(id) !== controller) return;
|
||||||
|
cancelScheduledRefresh(id);
|
||||||
|
sessions.delete(id);
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function observeTerminalDirectory(id: string) {
|
||||||
|
consumers.set(id, (consumers.get(id) ?? 0) + 1);
|
||||||
|
return () => {
|
||||||
|
const next = (consumers.get(id) ?? 1) - 1;
|
||||||
|
if (next) consumers.set(id, next);
|
||||||
|
else { consumers.delete(id); cancelScheduledRefresh(id); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Called by the existing command completion path, only while chips are visible. */
|
||||||
|
export function refreshObservedTerminalDirectory(id: string) {
|
||||||
|
cancelScheduledRefresh(id);
|
||||||
|
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
|
||||||
|
* completion. Debounce actual output/submission activity instead of polling or
|
||||||
|
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
|
||||||
|
* the bound shell's process cwd only as a fallback.
|
||||||
|
*/
|
||||||
|
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
|
||||||
|
if (!consumers.has(id) || !sessions.has(id)) return;
|
||||||
|
cancelScheduledRefresh(id);
|
||||||
|
scheduledRefreshes.set(id, setTimeout(() => {
|
||||||
|
scheduledRefreshes.delete(id);
|
||||||
|
refreshObservedTerminalDirectory(id);
|
||||||
|
}, OUTPUT_SETTLE_MS));
|
||||||
|
}
|
||||||
@@ -3,6 +3,37 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
|
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
|
||||||
|
|
||||||
|
test('recovery snapshots use the current handler and disappear on unmount', () => {
|
||||||
|
const registry = createTerminalReconnectRegistry();
|
||||||
|
assert.equal(registry.inspect('s'), null);
|
||||||
|
const unregister = registry.register('s', () => {}, () => false,
|
||||||
|
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
|
||||||
|
assert.equal(registry.inspect('s')?.waitingForInput, true);
|
||||||
|
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
|
||||||
|
unregister();
|
||||||
|
assert.equal(registry.inspect('s'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent reconnect never queues an unmounted terminal', () => {
|
||||||
|
const registry = createTerminalReconnectRegistry();
|
||||||
|
assert.equal(registry.requestIfAvailable('missing'), false);
|
||||||
|
let calls = 0;
|
||||||
|
registry.register('missing', () => { calls++; });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent reconnect respects live eligibility and active reconnects', () => {
|
||||||
|
const registry = createTerminalReconnectRegistry();
|
||||||
|
let eligible = false;
|
||||||
|
let calls = 0;
|
||||||
|
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
|
||||||
|
assert.equal(registry.requestIfAvailable('s'), false);
|
||||||
|
eligible = true;
|
||||||
|
assert.equal(registry.requestIfAvailable('s'), true);
|
||||||
|
assert.equal(registry.requestIfAvailable('s'), false);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('a registered terminal session can be reconnected on request', () => {
|
test('a registered terminal session can be reconnected on request', () => {
|
||||||
const registry = createTerminalReconnectRegistry();
|
const registry = createTerminalReconnectRegistry();
|
||||||
const requestedSessionIds: string[] = [];
|
const requestedSessionIds: string[] = [];
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
type TerminalReconnectHandler = () => void;
|
type TerminalReconnectHandler = () => void;
|
||||||
|
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
|
||||||
|
|
||||||
export const createTerminalReconnectRegistry = () => {
|
export const createTerminalReconnectRegistry = () => {
|
||||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||||
|
const eligibility = new Map<string, () => boolean>();
|
||||||
|
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
|
||||||
const pendingRequests = new Set<string>();
|
const pendingRequests = new Set<string>();
|
||||||
const activeSessionIds = new Set<string>();
|
const activeSessionIds = new Set<string>();
|
||||||
const enterReconnectEnabledIds = new Set<string>();
|
const enterReconnectEnabledIds = new Set<string>();
|
||||||
@@ -28,19 +31,40 @@ export const createTerminalReconnectRegistry = () => {
|
|||||||
return () => listeners.delete(listener);
|
return () => listeners.delete(listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
|
const register = (sessionId: string, handler: TerminalReconnectHandler,
|
||||||
|
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
|
||||||
handlers.set(sessionId, handler);
|
handlers.set(sessionId, handler);
|
||||||
|
eligibility.set(sessionId, canReconnect);
|
||||||
|
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
|
||||||
|
else recoveryReaders.delete(sessionId);
|
||||||
if (pendingRequests.delete(sessionId)) {
|
if (pendingRequests.delete(sessionId)) {
|
||||||
handler();
|
handler();
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (handlers.get(sessionId) === handler) {
|
if (handlers.get(sessionId) === handler) {
|
||||||
handlers.delete(sessionId);
|
handlers.delete(sessionId);
|
||||||
|
eligibility.delete(sessionId);
|
||||||
|
recoveryReaders.delete(sessionId);
|
||||||
setActive(sessionId, false);
|
setActive(sessionId, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inspect = (sessionId: string) => {
|
||||||
|
const state = recoveryReaders.get(sessionId)?.();
|
||||||
|
if (!state) return null;
|
||||||
|
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
|
||||||
|
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent requests must never survive unmount or bypass authentication/auto-retry.
|
||||||
|
const requestIfAvailable = (sessionId: string): boolean => {
|
||||||
|
const handler = handlers.get(sessionId);
|
||||||
|
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
|
||||||
|
handler();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const request = (sessionId: string): boolean => {
|
const request = (sessionId: string): boolean => {
|
||||||
if (isActive(sessionId)) return false;
|
if (isActive(sessionId)) return false;
|
||||||
const handler = handlers.get(sessionId);
|
const handler = handlers.get(sessionId);
|
||||||
@@ -66,7 +90,9 @@ export const createTerminalReconnectRegistry = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
inspect,
|
||||||
request,
|
request,
|
||||||
|
requestIfAvailable,
|
||||||
requestEnterReconnect,
|
requestEnterReconnect,
|
||||||
setEnterReconnectEnabled,
|
setEnterReconnectEnabled,
|
||||||
setActive,
|
setActive,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
|
||||||
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
|
||||||
|
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
|
||||||
|
const { default: React, act } = await import('react');
|
||||||
|
const { createRoot } = await import('react-dom/client');
|
||||||
|
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
|
||||||
|
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
|
||||||
|
const plugin = 'plugin-view:com.example.sidebar.page' as const;
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
|
||||||
|
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
|
||||||
|
let tools: typeof plugin[] = [];
|
||||||
|
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
|
||||||
|
const root = createRoot(document.getElementById('root')!);
|
||||||
|
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
|
||||||
|
try {
|
||||||
|
await render();
|
||||||
|
tools = [plugin]; await render();
|
||||||
|
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
|
||||||
|
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
|
||||||
|
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
|
||||||
|
tools = []; await render();
|
||||||
|
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
|
||||||
|
tools = [plugin]; await render();
|
||||||
|
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
|
||||||
|
await act(async () => { root.unmount(); });
|
||||||
|
const nextRoot = createRoot(document.getElementById('root')!);
|
||||||
|
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
|
||||||
|
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
|
||||||
|
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
|
||||||
|
await act(async () => { nextRoot.unmount(); });
|
||||||
|
} finally {
|
||||||
|
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
normalizeToolbarItemLayout,
|
normalizeToolbarItemLayout,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
|
||||||
|
|
||||||
export type TerminalSidePanelTabId = SidePanelTool;
|
export type TerminalSidePanelTabId = SidePanelTool;
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
|
|||||||
|
|
||||||
migrateSidePanelTabLayoutIfNeeded();
|
migrateSidePanelTabLayoutIfNeeded();
|
||||||
|
|
||||||
export function useTerminalSidePanelTabOrder(): {
|
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
|
||||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||||
layout: ToolbarItemLayout;
|
layout: ToolbarItemLayout;
|
||||||
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
|
|||||||
resetLayout: () => void;
|
resetLayout: () => void;
|
||||||
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
|
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
|
||||||
} {
|
} {
|
||||||
|
// Keep preferences for temporarily unavailable/disabled plugins, including
|
||||||
|
// across a cold start before the contribution catalog finishes loading.
|
||||||
|
const knownPluginTools = useRef<Set<string> | null>(null);
|
||||||
|
if (!knownPluginTools.current) {
|
||||||
|
let stored: unknown;
|
||||||
|
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
|
||||||
|
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
|
||||||
|
? (stored as { order?: unknown }).order : [];
|
||||||
|
knownPluginTools.current = new Set(Array.isArray(order)
|
||||||
|
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
|
||||||
|
}
|
||||||
|
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
|
||||||
|
const pluginKey = [...knownPluginTools.current].join('\0');
|
||||||
|
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
|
||||||
|
const plugins = pluginKey ? pluginKey.split('\0') : [];
|
||||||
|
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
|
||||||
|
}, [pluginKey]);
|
||||||
const {
|
const {
|
||||||
layout,
|
layout,
|
||||||
setPlacement,
|
setPlacement,
|
||||||
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
|
|||||||
partition,
|
partition,
|
||||||
} = useToolbarItemLayout(
|
} = useToolbarItemLayout(
|
||||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
||||||
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
|
defaults,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Newly installed plugins get a visible slot near the start even when an
|
||||||
|
// older saved layout already exists. Existing choices always keep their order.
|
||||||
|
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
|
||||||
|
useEffect(() => {
|
||||||
|
const active = activePluginKey ? activePluginKey.split('\0') : [];
|
||||||
|
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
|
||||||
|
let stored: unknown;
|
||||||
|
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
|
||||||
|
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
|
||||||
|
? (stored as { order?: unknown }).order : null;
|
||||||
|
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
|
||||||
|
const fresh = active.filter((id) => !saved.has(id));
|
||||||
|
if (!fresh.length) return;
|
||||||
|
const rest = layout.order.filter((id) => !fresh.includes(id));
|
||||||
|
const index = Math.max(0, rest.indexOf('sftp') + 1);
|
||||||
|
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
|
||||||
|
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
|
||||||
|
}, [activePluginKey, layout.order, setOrder]);
|
||||||
|
|
||||||
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
|
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
|
||||||
|
|
||||||
const setSidePanelTabOrder = useCallback(
|
const setSidePanelTabOrder = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Minimal in-memory IndexedDB stub covering the exact subset used by
|
||||||
|
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
|
||||||
|
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
|
||||||
|
* general-purpose IDB implementation — extend deliberately if the wrapper
|
||||||
|
* grows new usage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Resolve = () => void;
|
||||||
|
|
||||||
|
const microtask2 = (fn: Resolve) => {
|
||||||
|
queueMicrotask(() => queueMicrotask(fn));
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FakeRequest<T> {
|
||||||
|
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||||
|
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||||
|
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||||
|
result: T;
|
||||||
|
error: Error | null = null;
|
||||||
|
|
||||||
|
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
|
||||||
|
this.result = result;
|
||||||
|
if (mode === 'failure') {
|
||||||
|
this.error = error ?? new Error('fake idb failure');
|
||||||
|
queueMicrotask(() => this.onerror?.(this));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (mode === 'upgrade') this.onupgradeneeded?.(this);
|
||||||
|
this.onsuccess?.(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreSpec {
|
||||||
|
name: string;
|
||||||
|
keyPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FakeObjectStore {
|
||||||
|
/** Instrumentation for per-record write assertions. */
|
||||||
|
putCount = 0;
|
||||||
|
deleteCount = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly spec: StoreSpec,
|
||||||
|
public readonly records: Map<string, unknown>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
put(value: unknown): FakeRequest<IDBValidKey> {
|
||||||
|
this.putCount += 1;
|
||||||
|
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
|
||||||
|
this.records.set(key, value);
|
||||||
|
return new FakeRequest(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: IDBValidKey): FakeRequest<undefined> {
|
||||||
|
this.deleteCount += 1;
|
||||||
|
this.records.delete(String(key));
|
||||||
|
return new FakeRequest(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): FakeRequest<undefined> {
|
||||||
|
this.records.clear();
|
||||||
|
return new FakeRequest(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: IDBValidKey): FakeRequest<unknown> {
|
||||||
|
return new FakeRequest(this.records.get(String(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): FakeRequest<unknown[]> {
|
||||||
|
return new FakeRequest(Array.from(this.records.values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FakeTransaction {
|
||||||
|
oncomplete: Resolve | null = null;
|
||||||
|
onabort: ((ev?: unknown) => void) | null = null;
|
||||||
|
onerror: ((ev?: unknown) => void) | null = null;
|
||||||
|
error: Error | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly database: FakeIndexedDb,
|
||||||
|
private readonly storeName: string,
|
||||||
|
private readonly fail: boolean,
|
||||||
|
) {
|
||||||
|
microtask2(() => {
|
||||||
|
if (this.fail) {
|
||||||
|
this.error = new Error('fake transaction failure');
|
||||||
|
this.onerror?.(this);
|
||||||
|
this.onabort?.(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.oncomplete?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
objectStore(name: string): FakeObjectStore {
|
||||||
|
if (name !== this.storeName) {
|
||||||
|
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
|
||||||
|
}
|
||||||
|
return this.database.getOrCreateStore(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FakeIndexedDb {
|
||||||
|
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
|
||||||
|
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly storeSpecs = new Map<string, string>();
|
||||||
|
private readonly records = new Map<string, Map<string, unknown>>();
|
||||||
|
/** Instrumentation per store: { put, delete } counters. */
|
||||||
|
readonly opCounts = new Map<string, { put: number; delete: number }>();
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onversionchange: (() => void) | null = null;
|
||||||
|
|
||||||
|
createObjectStore(name: string, options?: { keyPath?: string }) {
|
||||||
|
this.objectStoreNames.add(name);
|
||||||
|
this.storeSpecs.set(name, options?.keyPath ?? 'key');
|
||||||
|
return { name };
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateStore(name: string): FakeObjectStore {
|
||||||
|
if (!this.storeSpecs.has(name)) {
|
||||||
|
this.storeSpecs.set(name, 'key');
|
||||||
|
this.objectStoreNames.add(name);
|
||||||
|
}
|
||||||
|
if (!this.records.has(name)) this.records.set(name, new Map());
|
||||||
|
const spec = { name, keyPath: this.storeSpecs.get(name)! };
|
||||||
|
const store = new FakeObjectStore(spec, this.records.get(name)!);
|
||||||
|
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
|
||||||
|
// Aggregate counts lazily via proxy-free bookkeeping on each op.
|
||||||
|
const originalPut = store.put.bind(store);
|
||||||
|
store.put = (value: unknown) => {
|
||||||
|
counts.put += 1;
|
||||||
|
this.opCounts.set(name, counts);
|
||||||
|
return originalPut(value);
|
||||||
|
};
|
||||||
|
const originalDelete = store.delete.bind(store);
|
||||||
|
store.delete = (key: IDBValidKey) => {
|
||||||
|
counts.delete += 1;
|
||||||
|
this.opCounts.set(name, counts);
|
||||||
|
return originalDelete(key);
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction(storeName: string, _mode?: string): FakeTransaction {
|
||||||
|
return new FakeTransaction(this, storeName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct read for assertions (bypasses the async wrapper). */
|
||||||
|
dumpStore(name: string): unknown[] {
|
||||||
|
return Array.from(this.records.get(name)?.values() ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeIndexedDbHandle {
|
||||||
|
db: FakeIndexedDb;
|
||||||
|
openCount: number;
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
|
||||||
|
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
|
||||||
|
const handle: FakeIndexedDbHandle = {
|
||||||
|
db: new FakeIndexedDb(),
|
||||||
|
openCount: 0,
|
||||||
|
restore: () => {
|
||||||
|
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (options.onOperation) {
|
||||||
|
const onOperation = options.onOperation;
|
||||||
|
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
|
||||||
|
handle.db.getOrCreateStore = (name: string) => {
|
||||||
|
const store = originalGet(name);
|
||||||
|
const originalPut = store.put.bind(store);
|
||||||
|
store.put = (value: unknown) => {
|
||||||
|
onOperation(name, 'put');
|
||||||
|
return originalPut(value);
|
||||||
|
};
|
||||||
|
const originalDelete = store.delete.bind(store);
|
||||||
|
store.delete = (key: IDBValidKey) => {
|
||||||
|
onOperation(name, 'delete');
|
||||||
|
return originalDelete(key);
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||||
|
open(_name: string, _version: number) {
|
||||||
|
handle.openCount += 1;
|
||||||
|
if (options.failOpen) {
|
||||||
|
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||||
|
}
|
||||||
|
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
|
||||||
|
// Fail only the first N opens, then recover (boot-retry scenarios).
|
||||||
|
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||||
|
}
|
||||||
|
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||||
|
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||||
STORAGE_KEY_AI_WEB_SEARCH,
|
STORAGE_KEY_AI_WEB_SEARCH,
|
||||||
@@ -34,6 +35,10 @@ import {
|
|||||||
normalizeResponseIdleTimeoutSeconds,
|
normalizeResponseIdleTimeoutSeconds,
|
||||||
} from '../../infrastructure/ai/types';
|
} from '../../infrastructure/ai/types';
|
||||||
import { removeProviderReferences } from './aiProviderCleanup';
|
import { removeProviderReferences } from './aiProviderCleanup';
|
||||||
|
import {
|
||||||
|
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||||
|
normalizeAiSessionMessageCap,
|
||||||
|
} from '../../domain/aiSessionMessageCap';
|
||||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||||
import { getAIBridge } from './aiStateSnapshots';
|
import { getAIBridge } from './aiStateSnapshots';
|
||||||
import { useStoredBoolean } from './useStoredBoolean';
|
import { useStoredBoolean } from './useStoredBoolean';
|
||||||
@@ -87,6 +92,12 @@ export function useAISettingsState() {
|
|||||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||||
);
|
);
|
||||||
|
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
|
||||||
|
normalizeAiSessionMessageCap(
|
||||||
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||||
|
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||||
|
)
|
||||||
|
);
|
||||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||||
);
|
);
|
||||||
@@ -197,6 +208,12 @@ export function useAISettingsState() {
|
|||||||
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setAiSessionMessageCap = useCallback((value: number) => {
|
||||||
|
const normalizedValue = normalizeAiSessionMessageCap(value);
|
||||||
|
setAiSessionMessageCapRaw(normalizedValue);
|
||||||
|
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||||
setWebSearchConfigRaw(config);
|
setWebSearchConfigRaw(config);
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -277,6 +294,14 @@ export function useAISettingsState() {
|
|||||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
|
||||||
|
setAiSessionMessageCapRaw(
|
||||||
|
normalizeAiSessionMessageCap(
|
||||||
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||||
|
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||||
break;
|
break;
|
||||||
@@ -340,6 +365,8 @@ export function useAISettingsState() {
|
|||||||
setResponseIdleTimeout,
|
setResponseIdleTimeout,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
setMaxIterations,
|
setMaxIterations,
|
||||||
|
aiSessionMessageCap,
|
||||||
|
setAiSessionMessageCap,
|
||||||
webSearchConfig,
|
webSearchConfig,
|
||||||
setWebSearchConfig,
|
setWebSearchConfig,
|
||||||
quickMessages,
|
quickMessages,
|
||||||
@@ -373,6 +400,8 @@ export function useAISettingsState() {
|
|||||||
setResponseIdleTimeout,
|
setResponseIdleTimeout,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
setMaxIterations,
|
setMaxIterations,
|
||||||
|
aiSessionMessageCap,
|
||||||
|
setAiSessionMessageCap,
|
||||||
webSearchConfig,
|
webSearchConfig,
|
||||||
setWebSearchConfig,
|
setWebSearchConfig,
|
||||||
quickMessages,
|
quickMessages,
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* Boot-scenario regression tests for the IndexedDB migration review fixes
|
||||||
|
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
|
||||||
|
* P2① baseline seeding after an IndexedDB failure,
|
||||||
|
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
|
||||||
|
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
|
||||||
|
* deletion.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import type { AISession } from '../../infrastructure/ai/types';
|
||||||
|
import {
|
||||||
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||||
|
STORAGE_KEY_AI_SESSIONS,
|
||||||
|
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||||
|
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||||
|
} from '../../infrastructure/config/storageKeys';
|
||||||
|
import { useAIState } from './useAIState';
|
||||||
|
import { getAISessionsSnapshot } from './aiSessionsStore';
|
||||||
|
import {
|
||||||
|
__flushIdbWritesForTests,
|
||||||
|
__resetAiSessionsHydrationForTests,
|
||||||
|
__resetBackupMirrorForTests,
|
||||||
|
__resetIdbPersistenceForTests,
|
||||||
|
getPersistedAiSessionsSnapshot,
|
||||||
|
flushBackupMirror,
|
||||||
|
latestAISessionsSnapshot,
|
||||||
|
setLatestAIActiveSessionMapSnapshot,
|
||||||
|
setLatestAIDraftsByScopeSnapshot,
|
||||||
|
setLatestAIPanelViewByScopeSnapshot,
|
||||||
|
setLatestAISessionsSnapshot,
|
||||||
|
} from './aiStateSnapshots';
|
||||||
|
import {
|
||||||
|
idbDeleteSessionIds,
|
||||||
|
AI_SESSIONS_META_MIGRATION_KEY,
|
||||||
|
idbPutSessions,
|
||||||
|
loadOrMigrateAiSessions,
|
||||||
|
openAiSessionsIdb,
|
||||||
|
resetAiSessionsIdbForTests,
|
||||||
|
} from './aiSessionIdbStorage';
|
||||||
|
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||||
|
|
||||||
|
function makeSession(id: string, updatedAt: number): AISession {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
agentId: 'catty',
|
||||||
|
scope: { type: 'global' },
|
||||||
|
messages: [],
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
} as AISession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDom() {
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||||
|
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||||
|
return dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mounted {
|
||||||
|
ai: ReturnType<typeof useAIState>;
|
||||||
|
root: ReturnType<typeof createRoot>;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
|
||||||
|
const holder: { ai?: ReturnType<typeof useAIState> } = {};
|
||||||
|
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
|
||||||
|
const mountAi = React.useRef(ai);
|
||||||
|
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function Harness() {
|
||||||
|
holder.ai = useAIState();
|
||||||
|
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => { root.render(React.createElement(Harness)); });
|
||||||
|
return { ai: holder.ai!, root, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unmount(mounted: Mounted, dom: JSDOM) {
|
||||||
|
await act(async () => { mounted.root.unmount(); });
|
||||||
|
mounted.container.remove();
|
||||||
|
dom.window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBootState() {
|
||||||
|
// Post-boot defaults first, then back to pending so every test starts
|
||||||
|
// from the pre-hydration state its own hook instance controls.
|
||||||
|
__resetIdbPersistenceForTests();
|
||||||
|
__resetAiSessionsHydrationForTests();
|
||||||
|
// Module-level snapshots leak across tests (the hook's initial state and
|
||||||
|
// the boot merge path read them) — reset to the cold-start state. The
|
||||||
|
// map snapshot must be null so the hook falls back to localStorage.
|
||||||
|
setLatestAISessionsSnapshot([]);
|
||||||
|
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
|
||||||
|
setLatestAIDraftsByScopeSnapshot({});
|
||||||
|
setLatestAIPanelViewByScopeSnapshot({});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
__resetBackupMirrorForTests();
|
||||||
|
resetBootState();
|
||||||
|
});
|
||||||
|
test.afterEach(() => { __resetBackupMirrorForTests(); });
|
||||||
|
|
||||||
|
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const deleted = makeSession('deleted-chat', 1);
|
||||||
|
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
|
||||||
|
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
|
||||||
|
let backupReads = 0;
|
||||||
|
const backupWrites: string[] = [];
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
netcatty: {
|
||||||
|
aiSessionsBackupReadLatest: async () => {
|
||||||
|
backupReads += 1;
|
||||||
|
return { ok: true, json: JSON.stringify([deleted]) };
|
||||||
|
},
|
||||||
|
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||||
|
backupWrites.push(json);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
flushBackupMirror();
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
|
||||||
|
assert.equal(backupReads, 0);
|
||||||
|
assert.deepEqual(backupWrites, []);
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), []);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const backup = [makeSession('backup-chat', 1)];
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||||
|
});
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
|
||||||
|
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const primary = makeSession('primary-chat', 3);
|
||||||
|
const chunk = makeSession('chunk-chat', 2);
|
||||||
|
const diskOnly = makeSession('disk-only-chat', 1);
|
||||||
|
const unmarked = makeSession('unmarked-deleted-chat', 0);
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||||
|
let recoveryAvailable = false;
|
||||||
|
let backupReads = 0;
|
||||||
|
const backupWrites: string[] = [];
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
netcatty: {
|
||||||
|
aiSessionsBackupReadLatest: async () => {
|
||||||
|
backupReads += 1;
|
||||||
|
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
|
||||||
|
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
|
||||||
|
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
|
||||||
|
return { ok: false };
|
||||||
|
},
|
||||||
|
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||||
|
backupWrites.push(json);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let mounted = await mountAiState();
|
||||||
|
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
|
||||||
|
try {
|
||||||
|
assert.equal(backupReads, 1);
|
||||||
|
assert.equal(hasMarker(), false);
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
|
||||||
|
flushBackupMirror();
|
||||||
|
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
|
||||||
|
|
||||||
|
// Ordinary edits and unload must also preserve the recovery generation.
|
||||||
|
await act(async () => {
|
||||||
|
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
|
||||||
|
assert.deepEqual(backupWrites, []);
|
||||||
|
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
|
||||||
|
assert.equal(updated.messages.length, 1);
|
||||||
|
assert.equal(hasMarker(), false);
|
||||||
|
|
||||||
|
await act(async () => { mounted.root.unmount(); });
|
||||||
|
mounted.container.remove();
|
||||||
|
resetBootState();
|
||||||
|
recoveryAvailable = true;
|
||||||
|
mounted = await mountAiState();
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
|
||||||
|
assert.equal(hasMarker(), true);
|
||||||
|
const expected = [updated, chunk, diskOnly];
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||||
|
flushBackupMirror();
|
||||||
|
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
|
||||||
|
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const stale: AISession = {
|
||||||
|
...makeSession('closed-chat', 1),
|
||||||
|
scope: { type: 'terminal', targetId: 'closed-terminal' },
|
||||||
|
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
|
||||||
|
};
|
||||||
|
const current: AISession = {
|
||||||
|
...stale,
|
||||||
|
updatedAt: 2,
|
||||||
|
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
|
||||||
|
};
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
|
||||||
|
await idbPutSessions(await openAiSessionsIdb(), [current]);
|
||||||
|
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||||
|
let snapshotAfterMaintenance: AISession[] | null | undefined;
|
||||||
|
let putsAfterMaintenance = 0;
|
||||||
|
const mounted = await mountAiState((ai) => {
|
||||||
|
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
|
||||||
|
snapshotAfterMaintenance = latestAISessionsSnapshot;
|
||||||
|
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
|
||||||
|
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
const expected = [{ ...current, scopeDangling: true }];
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||||
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const primary = makeSession('primary-chat', 3);
|
||||||
|
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
|
||||||
|
const overflowJson = JSON.stringify(overflow);
|
||||||
|
const split = Math.floor(overflowJson.length / 2);
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
|
||||||
|
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
const expected = [primary, ...overflow.slice(0, 2)];
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||||
|
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
|
||||||
|
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const primary = makeSession('primary-chat', 3);
|
||||||
|
const diskOnly = makeSession('disk-only-chat', 2);
|
||||||
|
const deleted = makeSession('deleted-chat', 1);
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||||
|
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
|
||||||
|
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
|
||||||
|
let backupReads = 0;
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
|
||||||
|
});
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
assert.equal(backupReads, 1);
|
||||||
|
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
|
||||||
|
await act(async () => {
|
||||||
|
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
|
||||||
|
});
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
|
||||||
|
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
|
||||||
|
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||||
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
|
||||||
|
|
||||||
|
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
|
||||||
|
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
// One open retry happened inside the boot effect: the legacy set now
|
||||||
|
// lives in IndexedDB (old code never retried and left the store empty).
|
||||||
|
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||||
|
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||||
|
assert.deepEqual(
|
||||||
|
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
|
||||||
|
['chat-a', 'chat-b'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const failing = installFakeIndexedDb({ failOpen: true });
|
||||||
|
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
|
||||||
|
try {
|
||||||
|
// The retry open also failed: nothing was persisted, and the diff
|
||||||
|
// baseline must remain unset.
|
||||||
|
assert.deepEqual(failing.db.dumpStore('sessions'), []);
|
||||||
|
|
||||||
|
// IndexedDB recovers, then the user edits ONE session. The full set
|
||||||
|
// must reach the store, not only the edited record.
|
||||||
|
failing.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
recovered = installFakeIndexedDb();
|
||||||
|
await act(async () => {
|
||||||
|
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
|
||||||
|
await act(async () => {});
|
||||||
|
await __flushIdbWritesForTests();
|
||||||
|
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||||
|
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
if (recovered) recovered.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P2②: pointer validation waits for hydration ──
|
||||||
|
|
||||||
|
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const source = makeSession('chat-1', 1);
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
|
||||||
|
dom.window.localStorage.setItem(
|
||||||
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||||
|
JSON.stringify({ 'terminal:t1': 'chat-1' }),
|
||||||
|
);
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
// Old code nulled the pointer here: the validation effect ran against
|
||||||
|
// the initially-empty in-memory session array.
|
||||||
|
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
// After hydration the session is restored and the pointer is intact.
|
||||||
|
await act(async () => {});
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||||
|
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
|
||||||
|
|
||||||
|
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
installFakeIndexedDb({ failOpen: true });
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
|
||||||
|
const backup = [makeSession('backup-chat', 5)];
|
||||||
|
const warns: unknown[][] = [];
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...args: unknown[]) => { warns.push(args); };
|
||||||
|
try {
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||||
|
});
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
await act(async () => {});
|
||||||
|
// The backup generation became the recovery source (old code: the
|
||||||
|
// failing IndexedDB recheck rejection was swallowed and the restore
|
||||||
|
// never ran).
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
|
||||||
|
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
|
||||||
|
// The unopenable-store error was surfaced, not swallowed.
|
||||||
|
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P2④: an empty getAll after hydration is an intentional deletion ──
|
||||||
|
|
||||||
|
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
|
||||||
|
const dom = installDom();
|
||||||
|
const idb = installFakeIndexedDb();
|
||||||
|
const source = makeSession('chat-1', 1);
|
||||||
|
await idbPutSessions(await openAiSessionsIdb(), [source]);
|
||||||
|
const mounted = await mountAiState();
|
||||||
|
try {
|
||||||
|
await act(async () => {});
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||||
|
// Another window deletes the last chat and announces the change. The
|
||||||
|
// announcement must come from a second BroadcastChannel: a channel never
|
||||||
|
// delivers to itself (the module-level channel is the receiving window).
|
||||||
|
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
|
||||||
|
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
|
||||||
|
await act(async () => {
|
||||||
|
otherWindow.postMessage(Date.now());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
});
|
||||||
|
otherWindow.close();
|
||||||
|
// Old code early-returned on the empty getAll and kept the deleted
|
||||||
|
// session alive locally.
|
||||||
|
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||||
|
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
|
||||||
|
} finally {
|
||||||
|
await unmount(mounted, dom);
|
||||||
|
idb.restore();
|
||||||
|
resetAiSessionsIdbForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
|
|||||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||||
import { useAIState } from './useAIState.ts';
|
import { useAIState } from './useAIState.ts';
|
||||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||||
|
|
||||||
const SOURCE_SESSION: AISession = {
|
const SOURCE_SESSION: AISession = {
|
||||||
id: 'chat-source',
|
id: 'chat-source',
|
||||||
@@ -121,11 +121,18 @@ for (const newerCount of [49, 50]) {
|
|||||||
let branch: AISession | null = null;
|
let branch: AISession | null = null;
|
||||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||||
assert.ok(branch);
|
assert.ok(branch);
|
||||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||||
assert.equal(saved.length, 50);
|
// nothing is evicted at these sizes) — not in a 50-capped localStorage
|
||||||
|
// blob.
|
||||||
|
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||||
|
assert.equal(saved.length, newerCount + 2);
|
||||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
await act(async () => root.unmount()); container.remove();
|
||||||
|
// Reload from the read-only legacy store (the stand-in for what the
|
||||||
|
// migration persists on a profile without IndexedDB).
|
||||||
|
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||||
|
dom.window.close();
|
||||||
const reloaded = await setupAiState(saved);
|
const reloaded = await setupAiState(saved);
|
||||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import type { AISession } from '../../infrastructure/ai/types';
|
||||||
|
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||||
|
import { useAIState } from './useAIState';
|
||||||
|
import { useAISessionsStore } from './aiSessionsStore';
|
||||||
|
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
|
||||||
|
|
||||||
|
for (const enabled of [true, false]) {
|
||||||
|
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||||
|
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||||
|
const source: AISession = {
|
||||||
|
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
|
||||||
|
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
|
||||||
|
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
|
||||||
|
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
|
||||||
|
};
|
||||||
|
setLatestAISessionsSnapshot([source]);
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
|
||||||
|
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
|
||||||
|
let ai: ReturnType<typeof useAIState>;
|
||||||
|
let sessions: readonly AISession[] = [];
|
||||||
|
function Harness() {
|
||||||
|
ai = useAIState();
|
||||||
|
sessions = useAISessionsStore().sessions;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => { root.render(React.createElement(Harness)); });
|
||||||
|
await act(async () => {
|
||||||
|
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
|
||||||
|
});
|
||||||
|
const updated = sessions.find(value => value.id === source.id)!;
|
||||||
|
assert.equal(updated.messages.length, enabled ? 502 : 500);
|
||||||
|
assert.equal(updated.messages[0].id, 'system');
|
||||||
|
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
|
||||||
|
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
|
||||||
|
assert.equal(updated.externalSessionId, 'native-thread');
|
||||||
|
} finally {
|
||||||
|
await act(async () => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
+403
-27
@@ -14,12 +14,15 @@ import {
|
|||||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||||
STORAGE_KEY_AI_SESSIONS,
|
STORAGE_KEY_AI_SESSIONS,
|
||||||
|
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||||
|
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||||
STORAGE_KEY_AI_WEB_SEARCH,
|
STORAGE_KEY_AI_WEB_SEARCH,
|
||||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||||
|
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||||
@@ -63,12 +66,23 @@ import {
|
|||||||
bumpDraftUploadGeneration,
|
bumpDraftUploadGeneration,
|
||||||
cleanupDeletedAIChatSessions,
|
cleanupDeletedAIChatSessions,
|
||||||
cleanupOrphanedAISessions,
|
cleanupOrphanedAISessions,
|
||||||
|
flushBackupMirror,
|
||||||
getAIBridge,
|
getAIBridge,
|
||||||
getDraftUploadGeneration,
|
getDraftUploadGeneration,
|
||||||
|
getPersistedAiSessionsSnapshot,
|
||||||
|
isAiSessionsHydrationComplete,
|
||||||
latestAIActiveSessionMapSnapshot,
|
latestAIActiveSessionMapSnapshot,
|
||||||
|
latestAIActiveTargetIdsSnapshot,
|
||||||
latestAIDraftsByScopeSnapshot,
|
latestAIDraftsByScopeSnapshot,
|
||||||
latestAIPanelViewByScopeSnapshot,
|
latestAIPanelViewByScopeSnapshot,
|
||||||
latestAISessionsSnapshot,
|
latestAISessionsSnapshot,
|
||||||
|
markAiSessionsHydrationComplete,
|
||||||
|
prepareRestoredSessions,
|
||||||
|
readAiSessionMessageCap,
|
||||||
|
rebindActiveSessionMap,
|
||||||
|
scheduleBackupMirror,
|
||||||
|
seedLastPersistedSessions,
|
||||||
|
setAiSessionsBackupRecoveryPending,
|
||||||
writeSessionsForStorage,
|
writeSessionsForStorage,
|
||||||
setLatestAIActiveSessionMapSnapshot,
|
setLatestAIActiveSessionMapSnapshot,
|
||||||
setLatestAIDraftsByScopeSnapshot,
|
setLatestAIDraftsByScopeSnapshot,
|
||||||
@@ -77,6 +91,26 @@ import {
|
|||||||
type DraftsByScope,
|
type DraftsByScope,
|
||||||
type PanelViewByScope,
|
type PanelViewByScope,
|
||||||
} from './aiStateSnapshots';
|
} from './aiStateSnapshots';
|
||||||
|
import {
|
||||||
|
AI_SESSIONS_IDB_VERSION,
|
||||||
|
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||||
|
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||||
|
idbGetAllSessions,
|
||||||
|
idbPutMeta,
|
||||||
|
idbPutSessions,
|
||||||
|
loadOrMigrateAiSessions,
|
||||||
|
mirrorActiveSessionMapToIdb,
|
||||||
|
openAiSessionsIdb,
|
||||||
|
publishAiSessionsIdbChange,
|
||||||
|
readActiveSessionMapFromIdb,
|
||||||
|
subscribeAiSessionsIdbChange,
|
||||||
|
} from './aiSessionIdbStorage';
|
||||||
|
import {
|
||||||
|
isMissingOrCorruptSessionsStore,
|
||||||
|
mergeImportedSessions,
|
||||||
|
parseBackupSessions,
|
||||||
|
readLiveSessionsArray,
|
||||||
|
} from './aiSessionsBackup';
|
||||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||||
import {
|
import {
|
||||||
persistCommandBlocklistSetting,
|
persistCommandBlocklistSetting,
|
||||||
@@ -166,11 +200,207 @@ export function useAIState() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Sessions ──
|
// ── Sessions ──
|
||||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
// Memory-only initial state: the primary store is IndexedDB and loads
|
||||||
latestAISessionsSnapshot
|
// asynchronously in the boot effect below (issue #4123). The legacy
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
// localStorage key is read-only fallback and must not pre-seed stale data.
|
||||||
?? []
|
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
|
||||||
);
|
// Issue #4125 review P2②: flips true when the boot load settles. The
|
||||||
|
// active-pointer validation effect waits for it — the initially-empty
|
||||||
|
// in-memory session array must never null the persisted pointers.
|
||||||
|
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
|
||||||
|
// 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(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let cleanupAfterHydration = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
|
||||||
|
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||||
|
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
|
||||||
|
const boot = await loadOrMigrateAiSessions(
|
||||||
|
async () => {
|
||||||
|
// Legacy retention split history between the primary key, JSON
|
||||||
|
// chunks, and explicitly marked disk-only records. Resolve all
|
||||||
|
// slices before the one-shot migration records completion.
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let index = 0; ; index += 1) {
|
||||||
|
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||||
|
const chunk = localStorageAdapter.readString(key);
|
||||||
|
if (chunk == null) break;
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
let legacy = mergeImportedSessions(
|
||||||
|
readLiveSessionsArray(chunks.join('')),
|
||||||
|
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||||
|
);
|
||||||
|
if (diskOnlyIds.size > 0) {
|
||||||
|
try {
|
||||||
|
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
|
||||||
|
if (backup?.ok) {
|
||||||
|
legacy = mergeImportedSessions(
|
||||||
|
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
|
||||||
|
legacy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(legacy);
|
||||||
|
},
|
||||||
|
[...diskOnlyIds],
|
||||||
|
);
|
||||||
|
setAiSessionsBackupRecoveryPending(boot.migrationPending);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (boot.idbFailed && boot.sessions.length > 0) {
|
||||||
|
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
|
||||||
|
// Persist it best-effort (one re-open retry) BEFORE seeding the
|
||||||
|
// diff baseline; if the store is still unreachable, leave the
|
||||||
|
// baseline unset so the next edit performs a full write instead of
|
||||||
|
// a diff that would drop the un-persisted sessions after IndexedDB
|
||||||
|
// recovers.
|
||||||
|
try {
|
||||||
|
const db = await openAiSessionsIdb();
|
||||||
|
if (!cancelled) {
|
||||||
|
await idbPutSessions(db, boot.sessions);
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||||
|
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||||
|
publishAiSessionsIdbChange();
|
||||||
|
seedLastPersistedSessions(boot.sessions);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
|
||||||
|
+ ' the diff baseline stays unset so the next edit rewrites every record:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seedLastPersistedSessions(boot.sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the mirrored active-pointer map when the localStorage key
|
||||||
|
// is missing (fresh profile restored from IndexedDB).
|
||||||
|
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
|
||||||
|
const mirrored = await readActiveSessionMapFromIdb();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (mirrored && Object.keys(mirrored).length > 0) {
|
||||||
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
|
||||||
|
setLatestAIActiveSessionMapSnapshot(mirrored);
|
||||||
|
setActiveSessionIdMapRaw(mirrored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootSessions = boot.sessions;
|
||||||
|
if (bootSessions.length > 0) {
|
||||||
|
const live = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||||
|
if (live.length > 0) {
|
||||||
|
// Sessions were created before the async load finished: merge
|
||||||
|
// (live records win by id) instead of clobbering either side.
|
||||||
|
const byId = new Map(bootSessions.map(session => [session.id, session]));
|
||||||
|
for (const session of live) byId.set(session.id, session);
|
||||||
|
const merged = Array.from(byId.values())
|
||||||
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
|
setLatestAISessionsSnapshot(merged);
|
||||||
|
sessionsRef.current = merged;
|
||||||
|
setSessionsRaw(merged);
|
||||||
|
writeSessionsForStorage(merged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLatestAISessionsSnapshot(bootSessions);
|
||||||
|
sessionsRef.current = bootSessions;
|
||||||
|
setSessionsRaw(bootSessions);
|
||||||
|
scheduleBackupMirror(JSON.stringify(bootSessions));
|
||||||
|
cleanupAfterHydration = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A previously initialized empty IDB is a committed deletion. An
|
||||||
|
// incomplete migration must retry only its marked disk-only records.
|
||||||
|
if (boot.authoritativeEmpty || boot.migrationPending) return;
|
||||||
|
|
||||||
|
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
|
||||||
|
// backup remains the last restore step. Only when the legacy store
|
||||||
|
// is also missing or corrupt — a valid empty legacy array is an
|
||||||
|
// intentional wipe and stays authoritative.
|
||||||
|
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bridge = getAIBridge();
|
||||||
|
if (!bridge?.aiSessionsBackupReadLatest) 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 persisted storage and in-memory snapshot/React state —
|
||||||
|
// createSession updates the snapshot before the debounced disk write.
|
||||||
|
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||||
|
if (sessionsRef.current.length > 0) return;
|
||||||
|
// Issue #4125 review P2③: one open retry already happened inside
|
||||||
|
// the boot load. If the store is still unopenable, do not let the
|
||||||
|
// recheck rejection reach the outer catch — the disk backup below
|
||||||
|
// is the recovery source.
|
||||||
|
try {
|
||||||
|
if ((await idbGetAllSessions()).length > 0) return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
|
||||||
|
+ ' applying the newest backup generation as the recovery source:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||||
|
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.
|
||||||
|
setLatestAISessionsSnapshot(restored);
|
||||||
|
writeSessionsForStorage(restored);
|
||||||
|
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||||
|
cleanupAfterHydration = true;
|
||||||
|
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 ${nextSessions.length} AI session(s) from the disk backup.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Boot load must never break startup — but it must not vanish
|
||||||
|
// silently either (issue #4125 review P2③).
|
||||||
|
console.warn('[AIState] AI session boot load failed:', error);
|
||||||
|
} finally {
|
||||||
|
markAiSessionsHydrationComplete();
|
||||||
|
if (!cancelled) {
|
||||||
|
// Maintenance may have mounted before hydration with unchanged live
|
||||||
|
// targets. Replay it now against the authoritative boot history.
|
||||||
|
if (cleanupAfterHydration) {
|
||||||
|
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||||
|
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||||
|
sessionsRef.current = nextSessions;
|
||||||
|
setSessionsRaw(nextSessions);
|
||||||
|
}
|
||||||
|
setSessionsHydrated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||||
const sessionsRef = useRef(sessions);
|
const sessionsRef = useRef(sessions);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,6 +463,9 @@ export function useAIState() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||||
|
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
|
||||||
|
// store (localStorage stays written at the mutation sites as before).
|
||||||
|
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
|
||||||
}, [activeSessionIdMap]);
|
}, [activeSessionIdMap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -244,6 +477,11 @@ export function useAIState() {
|
|||||||
}, [panelViewByScope]);
|
}, [panelViewByScope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
|
||||||
|
// the initially-empty in-memory array; validating pointers against it
|
||||||
|
// would null every pointer and break workspace restore. No pointer
|
||||||
|
// writes before hydration completes.
|
||||||
|
if (!sessionsHydrated) return;
|
||||||
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||||
@@ -272,7 +510,7 @@ export function useAIState() {
|
|||||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessions, activeSessionIdMap]);
|
}, [sessions, activeSessionIdMap, sessionsHydrated]);
|
||||||
|
|
||||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||||
@@ -464,7 +702,8 @@ export function useAIState() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorage = (e: StorageEvent) => {
|
const handleStorage = (e: StorageEvent) => {
|
||||||
try {
|
try {
|
||||||
switch (e.key) {
|
const key = e.key;
|
||||||
|
switch (key) {
|
||||||
case STORAGE_KEY_AI_PROVIDERS: {
|
case STORAGE_KEY_AI_PROVIDERS: {
|
||||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||||
if (parsed != null && !Array.isArray(parsed)) {
|
if (parsed != null && !Array.isArray(parsed)) {
|
||||||
@@ -556,7 +795,10 @@ export function useAIState() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case STORAGE_KEY_AI_SESSIONS: {
|
case STORAGE_KEY_AI_SESSIONS: {
|
||||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
// Session writes no longer land in localStorage (issue #4123);
|
||||||
|
// the cross-window signal is the IndexedDB change channel and the
|
||||||
|
// storage event only fires as a legacy fallback.
|
||||||
|
const nextSessions = getPersistedAiSessionsSnapshot();
|
||||||
setLatestAISessionsSnapshot(nextSessions);
|
setLatestAISessionsSnapshot(nextSessions);
|
||||||
setSessionsRaw(nextSessions);
|
setSessionsRaw(nextSessions);
|
||||||
break;
|
break;
|
||||||
@@ -598,8 +840,7 @@ export function useAIState() {
|
|||||||
case STORAGE_KEY_AI_SESSIONS:
|
case STORAGE_KEY_AI_SESSIONS:
|
||||||
setSessionsRaw(
|
setSessionsRaw(
|
||||||
latestAISessionsSnapshot
|
latestAISessionsSnapshot
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
?? getPersistedAiSessionsSnapshot(),
|
||||||
?? [],
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||||
@@ -626,6 +867,37 @@ export function useAIState() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cross-window AI session sync: session writes land in IndexedDB now, so
|
||||||
|
// the localStorage `storage` event no longer fires for them. Other windows
|
||||||
|
// announce their writes on a BroadcastChannel; the receiver reloads the
|
||||||
|
// store from IndexedDB.
|
||||||
|
useEffect(() => subscribeAiSessionsIdbChange(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const next = await idbGetAllSessions();
|
||||||
|
if (next.length === 0) {
|
||||||
|
if (!isAiSessionsHydrationComplete()) return;
|
||||||
|
// Issue #4125 review P2④: after hydration an empty store is an
|
||||||
|
// intentional cross-window deletion — adopt it instead of keeping
|
||||||
|
// the deleted session alive locally (its next edit would
|
||||||
|
// resurrect it). The pointer-validation effect clears the
|
||||||
|
// now-stale pointers. Only the pre-hydration window keeps the
|
||||||
|
// early-return: the boot load owns the store view until it
|
||||||
|
// settles.
|
||||||
|
setLatestAISessionsSnapshot([]);
|
||||||
|
seedLastPersistedSessions([]);
|
||||||
|
setSessionsRaw([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLatestAISessionsSnapshot(next);
|
||||||
|
seedLastPersistedSessions(next);
|
||||||
|
setSessionsRaw(next);
|
||||||
|
} catch {
|
||||||
|
// Cross-window refresh is best-effort.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}), []);
|
||||||
|
|
||||||
// ── Sync initial safety settings to MCP Server on mount ──
|
// ── Sync initial safety settings to MCP Server on mount ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bridge = getAIBridge();
|
const bridge = getAIBridge();
|
||||||
@@ -651,8 +923,8 @@ export function useAIState() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Session CRUD ──
|
// ── Session CRUD ──
|
||||||
const persistSessions = useCallback((next: AISession[]) => {
|
const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
|
||||||
writeSessionsForStorage(next);
|
writeSessionsForStorage(next, { deletedSessionIds });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||||
@@ -797,7 +1069,7 @@ export function useAIState() {
|
|||||||
setSessionsRaw(prev => {
|
setSessionsRaw(prev => {
|
||||||
const next = prev.filter(s => s.id !== sessionId);
|
const next = prev.filter(s => s.id !== sessionId);
|
||||||
setLatestAISessionsSnapshot(next);
|
setLatestAISessionsSnapshot(next);
|
||||||
persistSessions(next);
|
persistSessions(next, [sessionId]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (scopeKey) {
|
if (scopeKey) {
|
||||||
@@ -841,7 +1113,7 @@ export function useAIState() {
|
|||||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||||
});
|
});
|
||||||
setLatestAISessionsSnapshot(next);
|
setLatestAISessionsSnapshot(next);
|
||||||
persistSessions(next);
|
persistSessions(next, removedSessionIds);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
const scopeKey = `${scopeType}:${targetId}`;
|
const scopeKey = `${scopeType}:${targetId}`;
|
||||||
@@ -866,6 +1138,100 @@ export function useAIState() {
|
|||||||
});
|
});
|
||||||
}, [persistSessions]);
|
}, [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 importedIds = new Set(imported.map(session => session.id));
|
||||||
|
sessionsRef.current = next;
|
||||||
|
setLatestAISessionsSnapshot(next);
|
||||||
|
setSessionsRaw(next);
|
||||||
|
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
|
||||||
|
?? next;
|
||||||
|
sessionsRef.current = nextSessions;
|
||||||
|
setSessionsRaw(nextSessions);
|
||||||
|
setActiveSessionIdMapRaw(
|
||||||
|
latestAIActiveSessionMapSnapshot
|
||||||
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||||
|
?? {},
|
||||||
|
);
|
||||||
|
return nextSessions.filter(session => importedIds.has(session.id)).length;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||||
setSessionsRaw(prev => {
|
setSessionsRaw(prev => {
|
||||||
const next = prev.map(s => (
|
const next = prev.map(s => (
|
||||||
@@ -879,21 +1245,27 @@ export function useAIState() {
|
|||||||
});
|
});
|
||||||
}, [debouncedPersistSessions]);
|
}, [debouncedPersistSessions]);
|
||||||
|
|
||||||
// Maximum messages per session to prevent unbounded memory growth
|
// Local-only retention. Synced history must match the full-history storage
|
||||||
const MAX_MESSAGES_PER_SESSION = 500;
|
// policy. The in-memory bound follows the user-configurable per-session cap
|
||||||
|
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
|
||||||
|
// so the default behavior is unchanged and RAM stays bounded when the user
|
||||||
|
// lowers the persisted cap.
|
||||||
|
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
|
||||||
|
|
||||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||||
|
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||||
|
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
|
||||||
setSessionsRaw(prev => {
|
setSessionsRaw(prev => {
|
||||||
const next = prev.map(s => {
|
const next = prev.map(s => {
|
||||||
if (s.id !== sessionId) return s;
|
if (s.id !== sessionId) return s;
|
||||||
let msgs = [...s.messages, message];
|
let msgs = [...s.messages, message];
|
||||||
// Trim oldest messages if exceeding limit (keep system messages)
|
// Dropping synced prefixes makes the next cloud merge add them back.
|
||||||
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
if (!syncEnabled && msgs.length > inMemoryCap) {
|
||||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
|
||||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
|
||||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
|
||||||
}
|
}
|
||||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||||
});
|
});
|
||||||
@@ -1131,11 +1503,11 @@ export function useAIState() {
|
|||||||
|
|
||||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||||
cleanupOrphanedAISessions(activeTargetIds);
|
cleanupOrphanedAISessions(activeTargetIds);
|
||||||
|
if (!isAiSessionsHydrationComplete()) return;
|
||||||
|
|
||||||
const nextSessions =
|
const nextSessions =
|
||||||
latestAISessionsSnapshot
|
latestAISessionsSnapshot
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
?? getPersistedAiSessionsSnapshot();
|
||||||
?? [];
|
|
||||||
sessionsRef.current = nextSessions;
|
sessionsRef.current = nextSessions;
|
||||||
setSessionsRaw(nextSessions);
|
setSessionsRaw(nextSessions);
|
||||||
setActiveSessionIdMapRaw(
|
setActiveSessionIdMapRaw(
|
||||||
@@ -1187,8 +1559,7 @@ export function useAIState() {
|
|||||||
?? {};
|
?? {};
|
||||||
const currentSessions =
|
const currentSessions =
|
||||||
latestAISessionsSnapshot
|
latestAISessionsSnapshot
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
?? getPersistedAiSessionsSnapshot();
|
||||||
?? [];
|
|
||||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||||
const result = handoffDissolvedWorkspaceAIScope({
|
const result = handoffDissolvedWorkspaceAIScope({
|
||||||
activeSessionIdMap: currentMap,
|
activeSessionIdMap: currentMap,
|
||||||
@@ -1233,8 +1604,7 @@ export function useAIState() {
|
|||||||
?? {};
|
?? {};
|
||||||
const currentSessions =
|
const currentSessions =
|
||||||
latestAISessionsSnapshot
|
latestAISessionsSnapshot
|
||||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
?? getPersistedAiSessionsSnapshot();
|
||||||
?? [];
|
|
||||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||||
activeSessionIdMap: currentMap,
|
activeSessionIdMap: currentMap,
|
||||||
sessions: currentSessions,
|
sessions: currentSessions,
|
||||||
@@ -1370,6 +1740,9 @@ export function useAIState() {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
deleteSessionsByTarget,
|
deleteSessionsByTarget,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
|
rebindSessionScope,
|
||||||
|
exportSessionsBackup,
|
||||||
|
importSessionsBackup,
|
||||||
updateSessionExternalSessionId,
|
updateSessionExternalSessionId,
|
||||||
addMessageToSession,
|
addMessageToSession,
|
||||||
updateLastMessage,
|
updateLastMessage,
|
||||||
@@ -1432,6 +1805,9 @@ export function useAIState() {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
deleteSessionsByTarget,
|
deleteSessionsByTarget,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
|
rebindSessionScope,
|
||||||
|
exportSessionsBackup,
|
||||||
|
importSessionsBackup,
|
||||||
updateSessionExternalSessionId,
|
updateSessionExternalSessionId,
|
||||||
addMessageToSession,
|
addMessageToSession,
|
||||||
updateLastMessage,
|
updateLastMessage,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
|
|||||||
import { useAIState } from './useAIState.ts';
|
import { useAIState } from './useAIState.ts';
|
||||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||||
|
|
||||||
const SCOPE_KEY = 'terminal:terminal-a';
|
const SCOPE_KEY = 'terminal:terminal-a';
|
||||||
|
|
||||||
@@ -175,17 +175,21 @@ for (const newerCount of [49, 50]) {
|
|||||||
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
||||||
});
|
});
|
||||||
assert.ok(branchId);
|
assert.ok(branchId);
|
||||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||||
assert.equal(saved.length, 50);
|
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
|
||||||
|
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||||
|
assert.equal(saved.length, newerCount + 2);
|
||||||
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
||||||
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
||||||
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
||||||
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||||
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
||||||
await act(async () => root.unmount());
|
await act(async () => root.unmount());
|
||||||
container.remove(); dom.window.close();
|
container.remove();
|
||||||
|
// Seed the read-only legacy store with the persisted set (the stand-in
|
||||||
// Remount the actual hook from the persisted records, as on app reload.
|
// for what the migration persists on a profile without IndexedDB).
|
||||||
|
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||||
|
dom.window.close();
|
||||||
const reloaded = await setupAiState(saved);
|
const reloaded = await setupAiState(saved);
|
||||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||||
@@ -223,7 +227,7 @@ for (const viewingScope of [
|
|||||||
), 3);
|
), 3);
|
||||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
const saved = getPersistedAiSessionsSnapshot();
|
||||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||||
await act(async () => root.unmount());
|
await act(async () => root.unmount());
|
||||||
container.remove(); dom.window.close();
|
container.remove(); dom.window.close();
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
} from "./useActiveChromeTheme.ts";
|
} from "./useActiveChromeTheme.ts";
|
||||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
|
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
getSettingsChromeSnapshot,
|
||||||
|
publishSettingsChromeSnapshot,
|
||||||
|
} from "./settingsChromeStore.ts";
|
||||||
|
|
||||||
function createInlineStyle() {
|
function createInlineStyle() {
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
@@ -98,6 +102,193 @@ test("syncActiveChromeTheme skips surface refresh when the active theme fingerpr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const preference of ["system", "light", "dark"] as const) {
|
||||||
|
for (const chromeType of ["light", "dark"] as const) {
|
||||||
|
test(`syncActiveChromeTheme uses ${preference === "system" ? "system" : chromeType} native theme for ${preference} preference and ${chromeType} chrome`, () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { document?: Document; window?: Window };
|
||||||
|
const originalDocument = globals.document;
|
||||||
|
const originalWindow = globals.window;
|
||||||
|
const originalSnapshot = getSettingsChromeSnapshot();
|
||||||
|
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === chromeType);
|
||||||
|
assert.ok(theme);
|
||||||
|
const themeCalls: string[] = [];
|
||||||
|
const backgroundCalls: string[] = [];
|
||||||
|
globals.document = {
|
||||||
|
documentElement: {
|
||||||
|
dataset: {},
|
||||||
|
classList: { remove: () => {}, add: () => {} },
|
||||||
|
removeAttribute: () => {},
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
createElement: () => ({}),
|
||||||
|
head: { appendChild: () => {} },
|
||||||
|
querySelector: () => null,
|
||||||
|
} as unknown as Document;
|
||||||
|
globals.window = {
|
||||||
|
netcatty: {
|
||||||
|
setTheme: (value: string) => themeCalls.push(value),
|
||||||
|
setBackgroundColor: (value: string) => backgroundCalls.push(value),
|
||||||
|
},
|
||||||
|
} as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
try {
|
||||||
|
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: preference });
|
||||||
|
syncActiveChromeTheme(theme, () => {
|
||||||
|
throw new Error("app theme should not be restored for an active chrome theme");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(themeCalls, [preference === "system" ? "system" : chromeType]);
|
||||||
|
assert.deepEqual(backgroundCalls, [theme.colors.background]);
|
||||||
|
} finally {
|
||||||
|
publishSettingsChromeSnapshot(originalSnapshot);
|
||||||
|
if (originalDocument) {
|
||||||
|
globals.document = originalDocument;
|
||||||
|
} else {
|
||||||
|
delete globals.document;
|
||||||
|
}
|
||||||
|
if (originalWindow) {
|
||||||
|
globals.window = originalWindow;
|
||||||
|
} else {
|
||||||
|
delete globals.window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hanging jsdom/React render must fail this test quickly instead of
|
||||||
|
// consuming the whole CI job budget.
|
||||||
|
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
|
||||||
|
const { JSDOM } = await import("jsdom");
|
||||||
|
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalSnapshot = getSettingsChromeSnapshot();
|
||||||
|
const themeSources: string[] = [];
|
||||||
|
const backgrounds: string[] = [];
|
||||||
|
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||||
|
testWindow.netcatty = {
|
||||||
|
setTheme: async (source: string) => { themeSources.push(source); return true; },
|
||||||
|
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||||
|
} as unknown as NetcattyBridge;
|
||||||
|
globalThis.window = testWindow;
|
||||||
|
globalThis.document = testWindow.document;
|
||||||
|
try {
|
||||||
|
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||||
|
for (const type of ['light', 'dark'] as const) {
|
||||||
|
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||||
|
assert.ok(theme);
|
||||||
|
syncActiveChromeTheme(theme, () => {});
|
||||||
|
assert.equal(document.documentElement.classList.contains(type), true);
|
||||||
|
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||||
|
}
|
||||||
|
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
|
||||||
|
} finally {
|
||||||
|
publishSettingsChromeSnapshot(originalSnapshot);
|
||||||
|
globalThis.window = originalWindow;
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
|
||||||
|
const { JSDOM } = await import("jsdom");
|
||||||
|
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalSnapshot = getSettingsChromeSnapshot();
|
||||||
|
const backgrounds: string[] = [];
|
||||||
|
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||||
|
testWindow.netcatty = {
|
||||||
|
setTheme: (source: string) => {
|
||||||
|
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||||
|
} as unknown as NetcattyBridge;
|
||||||
|
globalThis.window = testWindow;
|
||||||
|
globalThis.document = testWindow.document;
|
||||||
|
try {
|
||||||
|
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||||
|
for (const type of ['light', 'dark'] as const) {
|
||||||
|
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||||
|
assert.ok(theme);
|
||||||
|
syncActiveChromeTheme(theme, () => {});
|
||||||
|
// The application settings effect runs first on an OS preference change.
|
||||||
|
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
|
||||||
|
backgrounds.push('application-background');
|
||||||
|
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
|
||||||
|
assert.equal(document.documentElement.classList.contains(type), true);
|
||||||
|
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||||
|
}
|
||||||
|
syncActiveChromeTheme(null, () => {
|
||||||
|
document.documentElement.className = 'light';
|
||||||
|
backgrounds.push('application-background');
|
||||||
|
});
|
||||||
|
assert.equal(document.documentElement.className, 'light');
|
||||||
|
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
|
||||||
|
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
|
||||||
|
assert.equal(backgrounds.at(-1), 'application-background');
|
||||||
|
} finally {
|
||||||
|
publishSettingsChromeSnapshot(originalSnapshot);
|
||||||
|
globalThis.window = originalWindow;
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
|
||||||
|
const { JSDOM } = await import('jsdom');
|
||||||
|
const { act, createElement } = await import('react');
|
||||||
|
const { createRoot } = await import('react-dom/client');
|
||||||
|
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
|
||||||
|
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
|
||||||
|
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalSnapshot = getSettingsChromeSnapshot();
|
||||||
|
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||||
|
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||||
|
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
const backgrounds: string[] = [];
|
||||||
|
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||||
|
globalThis.window = testWindow;
|
||||||
|
globalThis.document = dom.window.document;
|
||||||
|
testWindow.netcatty = {
|
||||||
|
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
|
||||||
|
} as unknown as NetcattyBridge;
|
||||||
|
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
|
||||||
|
assert.ok(theme);
|
||||||
|
const applyAppTheme = () => {};
|
||||||
|
function Harness() {
|
||||||
|
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
|
||||||
|
let renderer: ReturnType<typeof createRoot> | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
renderer = createRoot(document.createElement('div'));
|
||||||
|
renderer.render(createElement(Harness));
|
||||||
|
});
|
||||||
|
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||||
|
document.documentElement.className = 'light';
|
||||||
|
backgrounds.push('application-background');
|
||||||
|
await act(async () => {
|
||||||
|
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
|
||||||
|
});
|
||||||
|
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||||
|
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||||
|
} finally {
|
||||||
|
await act(async () => { renderer?.unmount(); });
|
||||||
|
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
|
||||||
|
publishSettingsChromeSnapshot(originalSnapshot);
|
||||||
|
globalThis.window = originalWindow;
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
dom.window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("active chrome theme foreground uses contrast calculation", () => {
|
test("active chrome theme foreground uses contrast calculation", () => {
|
||||||
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||||
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
||||||
import { useSettingsChromeStore } from './settingsChromeStore';
|
import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
|
||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import type { TerminalTheme } from "../../domain/models";
|
import type { TerminalTheme } from "../../domain/models";
|
||||||
import {
|
import {
|
||||||
@@ -237,9 +237,11 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
netcattyBridge.get()?.setTheme?.(targetClass);
|
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
|
||||||
|
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||||
|
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
|
||||||
|
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||||
}
|
}
|
||||||
applyTopTabsChromeThemeVars(theme);
|
applyTopTabsChromeThemeVars(theme);
|
||||||
@@ -248,10 +250,11 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
|||||||
export function syncActiveChromeTheme(
|
export function syncActiveChromeTheme(
|
||||||
activeTheme: TerminalTheme | null,
|
activeTheme: TerminalTheme | null,
|
||||||
applyAppTheme: () => void,
|
applyAppTheme: () => void,
|
||||||
|
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
|
||||||
): void {
|
): void {
|
||||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||||
if (nextFingerprint === appliedFingerprint) {
|
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,8 +284,11 @@ export function useActiveChromeTheme({
|
|||||||
applyAppThemeRef.current = applyAppTheme;
|
applyAppThemeRef.current = applyAppTheme;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
// Settings may have just applied the OS theme over a fixed terminal theme.
|
||||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
// Reassert its visual surfaces even when the terminal fingerprint is stable;
|
||||||
|
// tab-switch callers retain the default fingerprint fast path.
|
||||||
|
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
|
||||||
|
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Loaded 100 of 892 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user