mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba1b7b34a | ||
|
|
c309a455ed | ||
|
|
39a0008876 | ||
|
|
f5a802d610 | ||
|
|
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 | ||
|
|
f1410f8bb1 | ||
|
|
a4999fdf1d | ||
|
|
41313293e1 | ||
|
|
e907336ef5 | ||
|
|
d16e21c848 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
e6dd68b209 | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
eb6772355e | ||
|
|
8eefd9de96 | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
42a9826603 | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
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 || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Test tray panel layout
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
@@ -631,6 +634,9 @@ jobs:
|
||||
done
|
||||
npm run pack:linux-x64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
@@ -765,6 +771,9 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
|
||||
@@ -53,11 +53,17 @@ jobs:
|
||||
run: |
|
||||
npm run check:plugin-contract
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-examples
|
||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test SSH cipher negotiation in Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
@@ -144,5 +150,10 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -170,3 +170,6 @@ build_with_vs2022.bat
|
||||
/resources/et/*/et.exe
|
||||
/resources/et/*/et-*-dlls/
|
||||
/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
|
||||
protocol-failure containment. Runtime identity is assigned by the host and is
|
||||
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
|
||||
not add another independent quit interception path.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
@@ -88,6 +89,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginPermissionHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveHostAuth } from '../domain/sshAuth.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, Identity } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
// resolveHostAuth prefers identity.password, so the stale keychain secret
|
||||
// shadows whatever is already stored on the host.
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'stale-keychain-password',
|
||||
);
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate',
|
||||
['rotated-password'],
|
||||
'rotated-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'rotated-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, baseHost.username);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'rotated-password',
|
||||
);
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'deploy',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: 'root',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, 'deploy');
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'deploy');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'deploy');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'alice',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: '',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-blank-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-blank-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].username, 'alice');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'alice');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'alice');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||
|
||||
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 =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
@@ -393,6 +394,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
const {
|
||||
hosts,
|
||||
hostsRef,
|
||||
identities,
|
||||
keyboardInteractiveQueue,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
@@ -424,12 +426,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
// Save password to host if requested - never for second-factor / EDR prompts
|
||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||
// host login password (#2150 / Codex review on #2151).
|
||||
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
|
||||
// identity.password and identity.username, so snapshot the identity username
|
||||
// onto the host before clearing identityId (#3982). Same as
|
||||
// applyVaultHostUpdate's password detach. Do not mutate the identity.
|
||||
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
} : h));
|
||||
updateHosts(latestHosts.map((h: Host) => {
|
||||
if (h.id !== host.id) return h;
|
||||
const identity = h.identityId
|
||||
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
|
||||
: undefined;
|
||||
const detachedUsername = identity?.username?.trim();
|
||||
return {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
...(h.identityId ? {
|
||||
identityId: '',
|
||||
...(detachedUsername ? { username: detachedUsername } : {}),
|
||||
} : {}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
|
||||
@@ -59,6 +59,7 @@ import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
@@ -716,7 +717,7 @@ export function AppSideEffects() {
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
||||
@@ -1156,6 +1157,8 @@ export function AppSideEffects() {
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
|
||||
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
|
||||
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
@@ -856,6 +858,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||
<PluginAgentHost sessions={sessions} />
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||
|
||||
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
||||
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
|
||||
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.renameChat': 'Rename chat',
|
||||
'ai.chat.renameUsage': 'Usage: /rename <new title>',
|
||||
'ai.chat.renameNoActive': 'No active chat to rename.',
|
||||
'ai.chat.renameDone': 'Chat renamed to "{title}".',
|
||||
'ai.chat.exportSessions': 'Export AI chats',
|
||||
'ai.chat.importSessions': 'Import AI chats',
|
||||
'ai.chat.exportDone': 'AI chats exported to {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'AI chats exported.',
|
||||
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
|
||||
'ai.chat.importDone': 'Imported {n} AI chat(s).',
|
||||
'ai.chat.importNone': 'No AI chats found in the selected file.',
|
||||
'ai.chat.chatsHeader': 'Chats visible in this panel:',
|
||||
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Dissociated',
|
||||
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
|
||||
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
|
||||
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
|
||||
"ai.toolSettings.privacyOn": "Text redaction enabled",
|
||||
"ai.toolSettings.privacyOff": "Text redaction disabled",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
|
||||
"ai.toolSettings.configurePrivacy": "Configure privacy",
|
||||
"ai.toolSettings.unsaved": "Unsaved changes",
|
||||
"ai.toolSettings.viewSkills": "View CLI guidance",
|
||||
"ai.toolSettings.viewPrompt": "View setup prompt",
|
||||
"ai.toolSettings.behavior": "MCP behavior",
|
||||
"ai.toolSettings.configureBehavior": "Configure behavior",
|
||||
"ai.toolSettings.configureClients": "Configure clients",
|
||||
"ai.toolSettings.httpOn": "HTTP enabled",
|
||||
"ai.toolSettings.httpOff": "HTTP disabled",
|
||||
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configure HTTP",
|
||||
"ai.toolSettings.hideSecret": "Hide secret",
|
||||
"ai.toolSettings.showSecret": "Show secret",
|
||||
"ai.toolSettings.manualSetup": "Manual setup",
|
||||
"ai.toolSettings.format": "Format",
|
||||
"ai.toolSettings.connectionDetails": "Connection details",
|
||||
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
|
||||
"ai.toolSettings.retry": "Retry",
|
||||
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
|
||||
"settings.plugins.landing.updateAvailable": "Version {version} available",
|
||||
"settings.plugins.landing.loading": "Loading installed plugins…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
|
||||
"settings.plugins.landing.options": "Plugin options",
|
||||
"settings.plugins.landing.manager": "Plugin manager",
|
||||
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
|
||||
"settings.plugins.landing.browse": "Browse",
|
||||
"settings.plugins.landing.refresh": "Refresh installed plugins",
|
||||
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
|
||||
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
|
||||
"settings.plugins.landing.search": "Search installed plugins",
|
||||
"settings.plugins.landing.settingsFor": "Settings for {name}",
|
||||
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
|
||||
"settings.plugins.landing.sourceUnknown": "Source unknown",
|
||||
"settings.plugins.landing.status.running": "Running",
|
||||
"settings.plugins.landing.status.starting": "Starting",
|
||||
"settings.plugins.landing.status.stopping": "Stopping",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "Quarantined",
|
||||
"settings.plugins.landing.status.unknown": "Status unknown",
|
||||
"settings.plugins.landing.status.idle": "Enabled, idle",
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
@@ -135,7 +161,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.tempDirChoose': 'Choose folder',
|
||||
'settings.system.tempDirManual': 'Enter an absolute folder path',
|
||||
'settings.system.tempDirApply': 'Apply',
|
||||
'settings.system.tempDirReset': 'Reset to default',
|
||||
'settings.system.tempDirError': 'Unable to use this folder.',
|
||||
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
@@ -556,6 +587,8 @@ Highlight the focused split pane:
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
@@ -1236,6 +1269,8 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.author.official": "Official maintainer",
|
||||
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
@@ -1262,21 +1297,33 @@ Highlight the focused split pane:
|
||||
"plugins.interaction.share": "Share with plugin",
|
||||
"plugins.interaction.copy": "Copy to clipboard",
|
||||
"plugins.interaction.failed": "This request expired or could not be completed.",
|
||||
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.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.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.always": "Until revoked",
|
||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||
"settings.plugins.permissions.session": "Until the session closes",
|
||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. 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.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.required": "Required",
|
||||
"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.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
|
||||
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: 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.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
|
||||
"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.noResults": "No matching plugins. Try another search.",
|
||||
"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.openManager": "Open plugin manager",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Official",
|
||||
"settings.plugins.sources.short.thirdparty": "Third-party",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1351,6 +1401,7 @@ Highlight the focused split pane:
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.configuration": "Plugin settings",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||
@@ -1381,7 +1432,7 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
|
||||
'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',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.systemDisks": "System partitions ({count})",
|
||||
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
|
||||
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
||||
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
|
||||
@@ -525,6 +525,8 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
|
||||
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
@@ -587,6 +589,28 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.renameChat': 'Renombrar chat',
|
||||
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
|
||||
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
|
||||
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
|
||||
'ai.chat.exportSessions': 'Exportar chats de IA',
|
||||
'ai.chat.importSessions': 'Importar chats de IA',
|
||||
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
|
||||
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
|
||||
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
|
||||
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
|
||||
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
|
||||
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Desasociado',
|
||||
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
|
||||
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
|
||||
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
@@ -795,4 +819,27 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
|
||||
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
|
||||
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
|
||||
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
|
||||
"ai.toolSettings.unsaved": "Cambios sin guardar",
|
||||
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
|
||||
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
|
||||
"ai.toolSettings.behavior": "Comportamiento de MCP",
|
||||
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
|
||||
"ai.toolSettings.configureClients": "Configurar clientes",
|
||||
"ai.toolSettings.httpOn": "HTTP activado",
|
||||
"ai.toolSettings.httpOff": "HTTP desactivado",
|
||||
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configurar HTTP",
|
||||
"ai.toolSettings.hideSecret": "Ocultar secreto",
|
||||
"ai.toolSettings.showSecret": "Mostrar secreto",
|
||||
"ai.toolSettings.manualSetup": "Configuración manual",
|
||||
"ai.toolSettings.format": "Formato",
|
||||
"ai.toolSettings.connectionDetails": "Detalles de conexión",
|
||||
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
|
||||
"ai.toolSettings.retry": "Reintentar",
|
||||
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
|
||||
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
|
||||
"settings.plugins.landing.loading": "Cargando plugins instalados…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
|
||||
"settings.plugins.landing.options": "Opciones de plugins",
|
||||
"settings.plugins.landing.manager": "Gestor de plugins",
|
||||
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
|
||||
"settings.plugins.landing.browse": "Explorar",
|
||||
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
|
||||
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
|
||||
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
|
||||
"settings.plugins.landing.search": "Buscar plugins instalados",
|
||||
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
|
||||
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
|
||||
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
|
||||
"settings.plugins.landing.status.running": "En ejecución",
|
||||
"settings.plugins.landing.status.starting": "Iniciando",
|
||||
"settings.plugins.landing.status.stopping": "Deteniendo",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "En cuarentena",
|
||||
"settings.plugins.landing.status.unknown": "Estado desconocido",
|
||||
"settings.plugins.landing.status.idle": "Activado, en espera",
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
@@ -135,7 +161,12 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
|
||||
'settings.system.clearing': 'Limpiando...',
|
||||
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
|
||||
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
|
||||
'settings.system.tempDirChoose': 'Elegir carpeta',
|
||||
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
|
||||
'settings.system.tempDirApply': 'Aplicar',
|
||||
'settings.system.tempDirReset': 'Restablecer predeterminado',
|
||||
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
|
||||
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
|
||||
'settings.system.credentials.title': 'Protección de credenciales',
|
||||
'settings.system.credentials.status': 'Estado',
|
||||
'settings.system.credentials.checking': 'Comprobando...',
|
||||
@@ -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.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
@@ -1235,6 +1268,8 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.author.official": "Mantenimiento oficial",
|
||||
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
@@ -1262,9 +1297,21 @@ Resalta el panel dividido enfocado:
|
||||
"plugins.interaction.share": "Compartir con el complemento",
|
||||
"plugins.interaction.copy": "Copiar al portapapeles",
|
||||
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
|
||||
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.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.loading": "Cargando autorizaciones...",
|
||||
"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.unavailable": "La gestión de permisos no está disponible",
|
||||
"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.required": "Obligatorio",
|
||||
"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.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
|
||||
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: 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.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
|
||||
"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.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
|
||||
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. 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.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Oficial",
|
||||
"settings.plugins.sources.short.thirdparty": "Terceros",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1351,6 +1401,7 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.configuration": "Ajustes del plugin",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||
@@ -1381,7 +1432,7 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
|
||||
'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',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.systemDisks": "Particiones del sistema ({count})",
|
||||
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
|
||||
@@ -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.authReady': 'Tiempo de espera de autenticación SSH',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
|
||||
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
|
||||
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
|
||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||
|
||||
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
|
||||
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.renameChat': 'Переименовать чат',
|
||||
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
|
||||
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
|
||||
'ai.chat.renameDone': 'Чат переименован в «{title}».',
|
||||
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
|
||||
'ai.chat.importSessions': 'Импортировать чаты ИИ',
|
||||
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
|
||||
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
|
||||
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
|
||||
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
|
||||
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
|
||||
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Терминал',
|
||||
'ai.chat.chatsScopeGlobal': 'Глобально',
|
||||
'ai.chat.chatsScopeDangling': 'Связь потеряна',
|
||||
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
|
||||
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
|
||||
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
|
||||
"ai.toolSettings.privacyOn": "Скрытие текста включено",
|
||||
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
|
||||
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
|
||||
"ai.toolSettings.configurePrivacy": "Настроить приватность",
|
||||
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
|
||||
"ai.toolSettings.viewSkills": "Инструкции CLI",
|
||||
"ai.toolSettings.viewPrompt": "Инструкции подключения",
|
||||
"ai.toolSettings.behavior": "Поведение MCP",
|
||||
"ai.toolSettings.configureBehavior": "Настроить поведение",
|
||||
"ai.toolSettings.configureClients": "Настроить клиенты",
|
||||
"ai.toolSettings.httpOn": "HTTP включён",
|
||||
"ai.toolSettings.httpOff": "HTTP выключен",
|
||||
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Настроить HTTP",
|
||||
"ai.toolSettings.hideSecret": "Скрыть секрет",
|
||||
"ai.toolSettings.showSecret": "Показать секрет",
|
||||
"ai.toolSettings.manualSetup": "Ручная настройка",
|
||||
"ai.toolSettings.format": "Формат",
|
||||
"ai.toolSettings.connectionDetails": "Параметры подключения",
|
||||
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
|
||||
"ai.toolSettings.retry": "Повторить",
|
||||
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
|
||||
};
|
||||
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Очистить временные файлы',
|
||||
'settings.system.clearing': 'Очистка...',
|
||||
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
|
||||
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
|
||||
'settings.system.tempDirChoose': 'Выбрать папку',
|
||||
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
|
||||
'settings.system.tempDirApply': 'Применить',
|
||||
'settings.system.tempDirReset': 'Сбросить по умолчанию',
|
||||
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
|
||||
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
|
||||
'settings.system.credentials.title': 'Защита учётных данных',
|
||||
'settings.system.credentials.status': 'Статус',
|
||||
'settings.system.credentials.checking': 'Проверка...',
|
||||
@@ -525,6 +530,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
|
||||
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
@@ -1130,6 +1137,8 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.author.official": "Официальная поддержка",
|
||||
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||
@@ -1157,6 +1166,14 @@ export const ruCoreMessages: Messages = {
|
||||
"plugins.interaction.share": "Передать плагину",
|
||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
||||
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||
@@ -1171,7 +1188,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
||||
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
@@ -1214,7 +1231,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
|
||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||
"settings.plugins.market.retry": "Повторить",
|
||||
@@ -1223,11 +1240,14 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
|
||||
"settings.plugins.openView": "Открыть",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Официальный",
|
||||
"settings.plugins.sources.short.thirdparty": "Сторонний",
|
||||
"settings.plugins.sources.short.local": "Локальный",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1246,6 +1266,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.configuration": "Настройки плагина",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||
@@ -1276,7 +1297,7 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.systemDisks": "Системные разделы ({count})",
|
||||
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||
"monitor.diskIoTotal": "Всего",
|
||||
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
|
||||
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
|
||||
@@ -29,6 +29,16 @@ const ALL_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", () => {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
|
||||
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.renameChat': '重命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新标题>',
|
||||
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
|
||||
'ai.chat.exportSessions': '导出 AI 会话',
|
||||
'ai.chat.importSessions': '导入 AI 会话',
|
||||
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
|
||||
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
|
||||
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
|
||||
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
|
||||
'ai.chat.chatsHeader': '此面板可见的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作区',
|
||||
'ai.chat.chatsScopeTerminal': '终端',
|
||||
'ai.chat.chatsScopeGlobal': '全局',
|
||||
'ai.chat.chatsScopeDangling': '已断开关联',
|
||||
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
|
||||
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序号>',
|
||||
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
|
||||
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
|
||||
"ai.toolSettings.privacyOn": "已启用文本脱敏",
|
||||
"ai.toolSettings.privacyOff": "未启用文本脱敏",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
|
||||
"ai.toolSettings.configurePrivacy": "配置隐私",
|
||||
"ai.toolSettings.unsaved": "有未保存的更改",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示词",
|
||||
"ai.toolSettings.behavior": "MCP 行为",
|
||||
"ai.toolSettings.configureBehavior": "配置行为",
|
||||
"ai.toolSettings.configureClients": "配置客户端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已启用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未启用",
|
||||
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "配置 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隐藏密钥",
|
||||
"ai.toolSettings.showSecret": "显示密钥",
|
||||
"ai.toolSettings.manualSetup": "手动配置",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "连接详情",
|
||||
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
|
||||
"ai.toolSettings.retry": "重试",
|
||||
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在载入已安装插件…",
|
||||
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
|
||||
"settings.plugins.landing.options": "插件选项",
|
||||
"settings.plugins.landing.manager": "插件管理器",
|
||||
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
|
||||
"settings.plugins.landing.browse": "浏览",
|
||||
"settings.plugins.landing.refresh": "刷新已安装插件",
|
||||
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
|
||||
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
|
||||
"settings.plugins.landing.search": "搜索已安装插件",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的设置",
|
||||
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
|
||||
"settings.plugins.landing.sourceUnknown": "来源未知",
|
||||
"settings.plugins.landing.status.running": "运行中",
|
||||
"settings.plugins.landing.status.starting": "启动中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "错误",
|
||||
"settings.plugins.landing.status.quarantined": "已隔离",
|
||||
"settings.plugins.landing.status.unknown": "状态未知",
|
||||
"settings.plugins.landing.status.idle": "已启用,空闲",
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
@@ -139,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.tempDirChoose': '选择文件夹',
|
||||
'settings.system.tempDirManual': '输入绝对文件夹路径',
|
||||
'settings.system.tempDirApply': '应用',
|
||||
'settings.system.tempDirReset': '恢复默认',
|
||||
'settings.system.tempDirError': '无法使用此文件夹。',
|
||||
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
@@ -560,6 +591,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
@@ -1151,6 +1184,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.author.official": "官方维护",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
@@ -1177,21 +1212,33 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"plugins.interaction.share": "分享给插件",
|
||||
"plugins.interaction.copy": "复制到剪贴板",
|
||||
"plugins.interaction.failed": "此请求已过期或无法完成。",
|
||||
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
|
||||
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
|
||||
"plugins.interaction.terminalTarget": "所选终端",
|
||||
"plugins.interaction.terminalPreview": "预览终端选区",
|
||||
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
|
||||
"plugins.interaction.draft": "终端输入草稿",
|
||||
"plugins.interaction.exactText": "转义控制字符后的完整文本",
|
||||
"plugins.interaction.closeDraft": "关闭草稿",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"settings.plugins.permissions.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.loading": "正在读取授权...",
|
||||
"settings.plugins.permissions.loading": "正在加载权限...",
|
||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||
"settings.plugins.permissions.always": "直到撤销",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到会话关闭",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
|
||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
||||
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
@@ -1234,7 +1281,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
|
||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||
"settings.plugins.market.retry": "重试",
|
||||
@@ -1243,11 +1290,14 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||
"settings.plugins.market.manifest": "兼容性与权限",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本地",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
@@ -1266,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.configuration": "插件设置",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
@@ -1296,7 +1347,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.systemDisks": "系统分区({count})",
|
||||
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
|
||||
"monitor.diskIoTotal": "汇总",
|
||||
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
|
||||
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
|
||||
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
||||
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
'ai.chat.renameChat': '重新命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新標題>',
|
||||
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
|
||||
'ai.chat.exportSessions': '匯出 AI 會話',
|
||||
'ai.chat.importSessions': '匯入 AI 會話',
|
||||
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
|
||||
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
|
||||
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
|
||||
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
|
||||
'ai.chat.chatsHeader': '此面板可見的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作區',
|
||||
'ai.chat.chatsScopeTerminal': '終端',
|
||||
'ai.chat.chatsScopeGlobal': '全域',
|
||||
'ai.chat.chatsScopeDangling': '已斷開關聯',
|
||||
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
|
||||
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序號>',
|
||||
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
|
||||
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
|
||||
'ai.chat.justNow': '剛剛',
|
||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
|
||||
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
|
||||
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
|
||||
"ai.toolSettings.configurePrivacy": "設定隱私",
|
||||
"ai.toolSettings.unsaved": "有未儲存的變更",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示詞",
|
||||
"ai.toolSettings.behavior": "MCP 行為",
|
||||
"ai.toolSettings.configureBehavior": "設定行為",
|
||||
"ai.toolSettings.configureClients": "設定用戶端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已啟用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未啟用",
|
||||
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "設定 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隱藏金鑰",
|
||||
"ai.toolSettings.showSecret": "顯示金鑰",
|
||||
"ai.toolSettings.manualSetup": "手動設定",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "連線詳情",
|
||||
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
|
||||
"ai.toolSettings.retry": "重試",
|
||||
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
|
||||
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
|
||||
"settings.plugins.landing.options": "外掛選項",
|
||||
"settings.plugins.landing.manager": "外掛管理器",
|
||||
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
|
||||
"settings.plugins.landing.browse": "瀏覽",
|
||||
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
|
||||
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
|
||||
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
|
||||
"settings.plugins.landing.search": "搜尋已安裝外掛",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的設定",
|
||||
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
|
||||
"settings.plugins.landing.sourceUnknown": "來源不明",
|
||||
"settings.plugins.landing.status.running": "執行中",
|
||||
"settings.plugins.landing.status.starting": "啟動中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "錯誤",
|
||||
"settings.plugins.landing.status.quarantined": "已隔離",
|
||||
"settings.plugins.landing.status.unknown": "狀態不明",
|
||||
"settings.plugins.landing.status.idle": "已啟用,閒置",
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.cancel': '取消',
|
||||
@@ -141,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理臨時檔案',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
|
||||
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
|
||||
'settings.system.tempDirChoose': '選擇資料夾',
|
||||
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
|
||||
'settings.system.tempDirApply': '套用',
|
||||
'settings.system.tempDirReset': '恢復預設',
|
||||
'settings.system.tempDirError': '無法使用此資料夾。',
|
||||
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
|
||||
'settings.system.credentials.title': '憑證保護',
|
||||
'settings.system.credentials.status': '狀態',
|
||||
'settings.system.credentials.checking': '檢查中...',
|
||||
@@ -562,6 +593,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
|
||||
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
@@ -1153,6 +1186,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.author.official": "官方維護",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||
@@ -1180,21 +1215,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"plugins.interaction.share": "分享給外掛",
|
||||
"plugins.interaction.copy": "複製到剪貼簿",
|
||||
"plugins.interaction.failed": "此請求已過期或無法完成。",
|
||||
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
|
||||
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
|
||||
"plugins.interaction.terminalTarget": "所選終端",
|
||||
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
|
||||
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
|
||||
"plugins.interaction.draft": "終端輸入草稿",
|
||||
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
|
||||
"plugins.interaction.closeDraft": "關閉草稿",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"settings.plugins.permissions.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.loading": "正在讀取授權...",
|
||||
"settings.plugins.permissions.loading": "正在載入權限...",
|
||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||
"settings.plugins.permissions.always": "直到撤銷",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
|
||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
||||
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
@@ -1214,11 +1261,14 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本機",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
@@ -1237,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.configuration": "插件設定",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
@@ -1262,7 +1313,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
|
||||
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
|
||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||
"settings.plugins.market.retry": "重試",
|
||||
@@ -1299,7 +1350,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.systemDisks": "系統分割區({count})",
|
||||
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
|
||||
"monitor.diskIoTotal": "彙總",
|
||||
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
|
||||
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
|
||||
@@ -2,14 +2,9 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DebouncedBackupMirror,
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
shouldRestoreFromBackup,
|
||||
} from './aiSessionsBackup';
|
||||
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
|
||||
|
||||
function makeSession(id: string, updatedAt = 1_000): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
|
||||
assert.equal(isMissingOrCorruptSessionsStore(null), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore(''), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
|
||||
for (const raw of [null, undefined, '', '{}', 'not-json']) {
|
||||
assert.deepEqual(readLiveSessionsArray(raw), []);
|
||||
}
|
||||
const sessions = [makeSession('s1')];
|
||||
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
|
||||
});
|
||||
|
||||
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
|
||||
assert.equal(parseBackupSessions(null), null);
|
||||
assert.equal(parseBackupSessions(''), null);
|
||||
assert.equal(parseBackupSessions('not json'), null);
|
||||
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
|
||||
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
|
||||
const sessions = parseBackupSessions(JSON.stringify([
|
||||
{ id: 's1' },
|
||||
{ nope: true },
|
||||
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
|
||||
{ ...makeSession('null-scope'), scope: null },
|
||||
{ ...makeSession('missing-title'), title: undefined },
|
||||
{ ...makeSession('missing-agent'), agentId: undefined },
|
||||
{ ...makeSession('null-message'), messages: [null] },
|
||||
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
|
||||
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
|
||||
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
|
||||
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
|
||||
null,
|
||||
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
|
||||
]));
|
||||
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
test('malformed imports cannot reach pruning or inject broken sessions', () => {
|
||||
const current = [makeSession('live')];
|
||||
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
|
||||
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
|
||||
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
|
||||
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
|
||||
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
|
||||
});
|
||||
|
||||
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
|
||||
const backup = JSON.stringify([makeSession('s1')]);
|
||||
// Empty (or wiped) store -> restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('', backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('[]', backup), false);
|
||||
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
|
||||
// Non-empty live store -> never restore (explicit deletions must win).
|
||||
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
|
||||
// Empty/corrupt backup -> nothing to restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, null), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
|
||||
});
|
||||
|
||||
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
|
||||
const current = [makeSession('keep', 100), makeSession('older', 100)];
|
||||
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
|
||||
const merged = mergeImportedSessions(current, imported);
|
||||
const byId = new Map(merged.map((session) => [session.id, session]));
|
||||
assert.equal(byId.get('older')?.updatedAt, 100);
|
||||
assert.equal(byId.get('newer')?.updatedAt, 200);
|
||||
assert.equal(byId.has('added'), true);
|
||||
assert.equal(byId.has('keep'), true);
|
||||
// Sorted newest-first for stable history ordering.
|
||||
assert.deepEqual(
|
||||
merged.map((session) => session.updatedAt),
|
||||
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
|
||||
);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.schedule('{"gen":2}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
mirror.schedule('{"gen":3}');
|
||||
mirror.schedule('{"gen":4}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
// A second flush with nothing pending is a no-op.
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
|
||||
assert.deepEqual(readLiveSessionsArray(null), []);
|
||||
assert.deepEqual(readLiveSessionsArray(''), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('not-json'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('[]'), []);
|
||||
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Renderer-side logic for the AI session durability layer (issue #4015 R2
|
||||
* follow-up): a disk mirror of the localStorage session store plus a
|
||||
* restore-on-empty-store boot path. The IPC plumbing lives in
|
||||
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
|
||||
* decisions pure so they can be tested without Electron.
|
||||
*/
|
||||
|
||||
/** Debounce window: at most one backup write per mutation burst (~2s). */
|
||||
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface AiSessionsBackupBridge {
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
/** Restore only when the live store is missing or malformed. A valid empty
|
||||
* array represents an intentional deletion and must remain authoritative. */
|
||||
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the live sessions array from a raw localStorage string. Returns [] when
|
||||
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
|
||||
* treat a non-array as AISession[]. */
|
||||
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a backup payload into sessions. Returns null when the payload is
|
||||
* corrupt or malformed (ignored gracefully); also drops entries without a
|
||||
* usable required fields. Backup generations are written by the same serializer as the
|
||||
* live store, so entries are expected to be full AISession objects.
|
||||
*/
|
||||
|
||||
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
|
||||
if (typeof json !== 'string' || json === '') return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const sessions = parsed.filter(
|
||||
(entry): entry is AISession => (
|
||||
typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as AISession).id === 'string'
|
||||
&& typeof (entry as AISession).title === 'string'
|
||||
&& typeof (entry as AISession).agentId === 'string'
|
||||
&& typeof (entry as AISession).scope === 'object'
|
||||
&& (entry as AISession).scope !== null
|
||||
&& typeof (entry as AISession).scope.type === 'string'
|
||||
&& Array.isArray((entry as AISession).messages)
|
||||
&& (entry as AISession).messages.every(message => (
|
||||
typeof message === 'object' && message !== null
|
||||
&& typeof message.id === 'string'
|
||||
&& typeof message.role === 'string'
|
||||
&& typeof message.content === 'string'
|
||||
&& typeof message.timestamp === 'number'
|
||||
&& Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof (entry as AISession).updatedAt === 'number'
|
||||
&& Number.isFinite((entry as AISession).updatedAt)
|
||||
),
|
||||
);
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
|
||||
* the backup actually contains at least one session.
|
||||
*/
|
||||
export function shouldRestoreFromBackup(
|
||||
liveRaw: string | null | undefined,
|
||||
backupJson: string | null | undefined,
|
||||
): boolean {
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
|
||||
const sessions = parseBackupSessions(backupJson);
|
||||
return sessions != null && sessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an imported backup into the current store: entries are keyed by id
|
||||
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
|
||||
* the live store (unlike the boot restore, which is empty-only).
|
||||
*/
|
||||
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
|
||||
const byId = new Map<string, AISession>();
|
||||
for (const session of current) byId.set(session.id, session);
|
||||
for (const session of imported) {
|
||||
const existing = byId.get(session.id);
|
||||
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
|
||||
byId.set(session.id, session);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced mirror scheduler. One rolling timer per process: the last
|
||||
* mutation batch wins, and the write happens `debounceMs` after the batch
|
||||
* settles (at most once per batch, per the durability spec).
|
||||
*/
|
||||
export class DebouncedBackupMirror {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingJson: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly write: (json: string) => void,
|
||||
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
|
||||
) {}
|
||||
|
||||
schedule(json: string) {
|
||||
this.pendingJson = json;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
flushNow() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pendingJson = null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
rebindActiveSessionMap,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
// Issue #4015 R2: the dropped chat is marked dangling but its record and
|
||||
// its active pointer stay intact so /chats can offer a rebind.
|
||||
const persistedSessions = latestAISessionsSnapshot;
|
||||
assert.ok(persistedSessions);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const dangling = {
|
||||
...makeSession('dangling-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
|
||||
};
|
||||
const live = {
|
||||
...makeSession('live-chat', 2, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'open' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([dangling, live]);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open']));
|
||||
|
||||
let persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
|
||||
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
|
||||
// The active pointer survives the dangling marking.
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
assert.ok(writeString.mock.callCount() > 0);
|
||||
|
||||
// The scope target becomes live again → the dangling flag clears.
|
||||
read.mock.mockImplementation(() => null);
|
||||
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
|
||||
persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const orphan = {
|
||||
...makeSession('orphan-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'gone' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([orphan]);
|
||||
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
|
||||
|
||||
try {
|
||||
// Empty is a valid observed live set (nothing mounted), not "unknown".
|
||||
cleanupOrphanedAISessions(new Set());
|
||||
const persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
|
||||
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
|
||||
await import('./aiStateSnapshots');
|
||||
__resetBackupMirrorForTests();
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
aiSessionsBackupWrite: async (payload: { json: string }) => {
|
||||
backupWrites.push(payload.json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
// Throttled: nothing has been written to disk yet.
|
||||
assert.equal(backupWrites.length, 0);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
// The mirror carries exactly the JSON that landed in localStorage.
|
||||
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
|
||||
// Failed store writes never schedule a mirror.
|
||||
writeString.mock.mockImplementation(() => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
|
||||
const cleaned = rebindActiveSessionMap(
|
||||
{
|
||||
'terminal:old-ws': 'chat-1',
|
||||
'terminal:other': 'chat-2',
|
||||
'workspace:keep': 'chat-1',
|
||||
},
|
||||
'chat-1',
|
||||
'terminal:new-ws',
|
||||
);
|
||||
assert.deepEqual(cleaned, {
|
||||
'terminal:other': 'chat-2',
|
||||
'terminal:new-ws': 'chat-1',
|
||||
});
|
||||
// Idempotent when already bound to the target scope with no other pointers.
|
||||
assert.deepEqual(
|
||||
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
|
||||
{ 'terminal:new-ws': 'chat-1' },
|
||||
);
|
||||
});
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -35,6 +36,11 @@ export interface AIBridge {
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
/** Durability mirror: write one timestamped backup generation to disk. */
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
export function getAIBridge() {
|
||||
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
/** Last live terminal/workspace target ids observed by orphan cleanup. */
|
||||
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
|
||||
// instead of stripping its record or pointer. `/chats` lists dangling chats
|
||||
// with a rebind affordance; the flag clears when the chat is rebound or its
|
||||
// scope target becomes live again.
|
||||
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
|
||||
let sessionsMarked = false;
|
||||
const markedSessions = currentSessions.map((session) => {
|
||||
if (orphanedIds.has(session.id)) {
|
||||
if (session.scopeDangling) return session;
|
||||
sessionsMarked = true;
|
||||
return { ...session, scopeDangling: true };
|
||||
}
|
||||
if (
|
||||
session.scopeDangling
|
||||
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
|
||||
) {
|
||||
sessionsMarked = true;
|
||||
const { scopeDangling: _cleared, ...rest } = session;
|
||||
return rest as AISession;
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
// Issue #4015 R2: keep active-pointer entries for scopes whose target
|
||||
// closed. Clearing them used to make dangling chats invisible (rank 0) and
|
||||
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
const prunedScopedState = pruneInactiveScopedState(
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
if (scopeKey in prunedScopedState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
if (scopeKey in prunedScopedState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. The exact JSON that landed in
|
||||
// localStorage is mirrored (already byte-budgeted), throttled to at most one
|
||||
// write per mutation batch.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
}
|
||||
});
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
// Unload and persistence must remain fail-safe.
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
|
||||
return syncedOk;
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
|
||||
scheduleBackupMirror(candidate.json);
|
||||
return true;
|
||||
}
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
|
||||
/**
|
||||
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
|
||||
* chat. Drops every pointer that still names `sessionId` under a different
|
||||
* scope key, then sets the new scope as active. Callers must persist and
|
||||
* publish the returned map (do not rebuild from a stale snapshot).
|
||||
*/
|
||||
export function rebindActiveSessionMap(
|
||||
activeSessionIdMap: Record<string, string | null>,
|
||||
sessionId: string,
|
||||
scopeKey: string,
|
||||
): Record<string, string | null> {
|
||||
let next = activeSessionIdMap;
|
||||
for (const [key, id] of Object.entries(activeSessionIdMap)) {
|
||||
if (id !== sessionId || key === scopeKey) continue;
|
||||
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
|
||||
delete next[key];
|
||||
}
|
||||
if (next[scopeKey] === sessionId) return next;
|
||||
return { ...next, [scopeKey]: sessionId };
|
||||
}
|
||||
|
||||
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
|
||||
['tab', 'tab'],
|
||||
]);
|
||||
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'[data-plugin-editing]',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'.monaco-menu-container',
|
||||
].join(', ');
|
||||
|
||||
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
|
||||
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
|
||||
}
|
||||
|
||||
function hasEditableShortcutAncestor(node: unknown): boolean {
|
||||
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
|
||||
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 test from 'node:test';
|
||||
|
||||
import {
|
||||
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);
|
||||
});
|
||||
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore';
|
||||
|
||||
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
||||
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(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> {
|
||||
requestViewId: string;
|
||||
contextKey: string;
|
||||
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
|
||||
store.refreshMetadata(entries);
|
||||
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_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY) {
|
||||
setWindowOpacity(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
|
||||
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
|
||||
const nextVariant = resolveAppIconVariant(value);
|
||||
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
|
||||
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -523,6 +526,12 @@ export function useSettingsStorageSync({
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
setWindowOpacity(e.newValue);
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.windowBackgroundBlur) {
|
||||
setWindowBackgroundBlur(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
|
||||
const newValue = resolveAppIconVariant(e.newValue);
|
||||
if (newValue !== s.appIconVariant) {
|
||||
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
|
||||
setGlobalHotkeyEnabled,
|
||||
setHostClickBehaviorState,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isMacPlatform } from '../../lib/utils';
|
||||
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
serializeWindowOpacityRecord,
|
||||
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
|
||||
persistMountedRef,
|
||||
]);
|
||||
|
||||
// Window chrome also applies in detached sessions, independently of the
|
||||
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
|
||||
// Only the windows that own settings persistence drive the shared native
|
||||
// state; detached/editor windows consume it (see applySharedWindowSetting).
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
|
||||
}, [
|
||||
enabled,
|
||||
windowBackgroundBlur,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
]);
|
||||
|
||||
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
|
||||
windowBackgroundBlur,
|
||||
windowOpacityRecord.opacity,
|
||||
);
|
||||
|
||||
// Applies in every app-content window, including detached sessions: the
|
||||
// transparent backing only looks right when the renderer class matches the
|
||||
// native vibrancy the main process put on that window.
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
|
||||
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
|
||||
return () => {
|
||||
document.documentElement.classList.remove('window-background-blur');
|
||||
document.documentElement.style.removeProperty('--window-content-opacity');
|
||||
};
|
||||
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
|
||||
|
||||
// setWindowBackgroundBlur is a main-process handler that retargets every
|
||||
// app-content window, so a single owner must drive it.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
|
||||
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
|
||||
});
|
||||
}, [enabled, windowBackgroundBlur]);
|
||||
|
||||
// Persist and sync app icon variant
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
|
||||
type Harness = {
|
||||
dom: JSDOM;
|
||||
params: Parameters<typeof useSystemSettingsEffects>[0];
|
||||
bridgeRequests: boolean[];
|
||||
blurNotifications: unknown[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
|
||||
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
|
||||
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const replacements = {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
navigator: dom.window.navigator,
|
||||
localStorage: dom.window.localStorage,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true });
|
||||
}
|
||||
const bridgeRequests: boolean[] = [];
|
||||
const blurNotifications: unknown[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
|
||||
},
|
||||
});
|
||||
const noop = () => {};
|
||||
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
|
||||
enabled,
|
||||
toggleWindowHotkey: '',
|
||||
globalHotkeyEnabled: false,
|
||||
closeToTray: false,
|
||||
showTrayIcon: false,
|
||||
autoLaunchEnabled: false,
|
||||
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
|
||||
windowOpacityMutationSourceRef: { current: 'local' },
|
||||
windowBackgroundBlur,
|
||||
appIconVariant: 'original',
|
||||
autoUpdateEnabled: false,
|
||||
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
|
||||
persistMountedRef: { current: true },
|
||||
setHotkeyRegistrationError: noop,
|
||||
setAutoUpdateEnabled: noop,
|
||||
setAppIconVariant: noop,
|
||||
setAutoLaunchEnabled: noop,
|
||||
setAutoLaunchSupported: noop,
|
||||
notifySettingsChanged: (key, value) => {
|
||||
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
|
||||
},
|
||||
};
|
||||
return {
|
||||
dom,
|
||||
params,
|
||||
bridgeRequests,
|
||||
blurNotifications,
|
||||
restore: () => {
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.window.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
|
||||
useSystemSettingsEffects(props);
|
||||
return null;
|
||||
};
|
||||
|
||||
test('opacity changes and remounts preserve the chosen blur preference', async () => {
|
||||
const harness = setUp(true, true);
|
||||
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
|
||||
assert.deepEqual(bridgeRequests, [true]);
|
||||
assert.deepEqual(blurNotifications, [true]);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.deepEqual(bridgeRequests, [true, true, false]);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('detached session windows only apply the blur class, never own the setting', async () => {
|
||||
// Peer session windows run with enableSystemEffects off. They must still
|
||||
// paint the transparent backing, but persistence, cross-window broadcast and
|
||||
// the (all-windows) native vibrancy IPC stay with the settings owners.
|
||||
const harness = setUp(false, true);
|
||||
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, params)); });
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
|
||||
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
|
||||
assert.deepEqual(bridgeRequests, []);
|
||||
assert.deepEqual(blurNotifications, []);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
|
||||
assert.deepEqual(bridgeRequests, []);
|
||||
assert.deepEqual(blurNotifications, []);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
normalizeToolbarItemLayout,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
|
||||
|
||||
migrateSidePanelTabLayoutIfNeeded();
|
||||
|
||||
export function useTerminalSidePanelTabOrder(): {
|
||||
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
|
||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||
layout: ToolbarItemLayout;
|
||||
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
|
||||
resetLayout: () => void;
|
||||
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 {
|
||||
layout,
|
||||
setPlacement,
|
||||
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
|
||||
partition,
|
||||
} = useToolbarItemLayout(
|
||||
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 setSidePanelTabOrder = useCallback(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
@@ -63,12 +64,17 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
prepareRestoredSessions,
|
||||
scheduleBackupMirror,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -77,6 +83,12 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
@@ -168,9 +180,76 @@ export function useAIState() {
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? []
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
|
||||
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
|
||||
return;
|
||||
}
|
||||
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover both persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
// marking even when the records later land in React state.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
@@ -866,6 +945,101 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
|
||||
// Rewrites `scope`, clears the dangling marker and makes the chat the
|
||||
// active session of its new scope so it shows in the scoped history again.
|
||||
const rebindSessionScope = useCallback((
|
||||
sessionId: string,
|
||||
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
|
||||
) => {
|
||||
const scopeKey = `${scope.type}:${scope.targetId}`;
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const { scopeDangling: _cleared, ...rest } = s;
|
||||
// Replace scope entirely (including hostIds) so history matching follows
|
||||
// the destination terminal/workspace rather than the chat's old host.
|
||||
const nextScope: AISession['scope'] = {
|
||||
type: scope.type,
|
||||
targetId: scope.targetId,
|
||||
};
|
||||
if (scope.hostIds && scope.hostIds.length > 0) {
|
||||
nextScope.hostIds = [...scope.hostIds];
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
scope: nextScope,
|
||||
updatedAt: Date.now(),
|
||||
} as AISession;
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
// Drop stale pointers that still reference this chat under its previous
|
||||
// scope key, then restore the active pointer for the new scope.
|
||||
// Compute + persist inside the updater so a deferred React setState still
|
||||
// writes the cleaned map (outer capture would stay null and skip persist).
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
// Open the rebound chat in this panel; active-map alone loses to an
|
||||
// explicit draft/session panelView that still points elsewhere.
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [persistSessions, setPanelViewByScope]);
|
||||
|
||||
// Manual durability escape hatch (issue #4015 follow-up): export the
|
||||
// current store to a user-chosen JSON file, or import one back in.
|
||||
// Import is an explicit action, so it may overwrite the live store.
|
||||
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupExport) return null;
|
||||
const result = await bridge.aiSessionsBackupExport({
|
||||
json: JSON.stringify(sessionsRef.current),
|
||||
});
|
||||
return result?.success ? (result.filePath ?? '') : null;
|
||||
}, []);
|
||||
|
||||
const importSessionsBackup = useCallback(async (): Promise<number> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupImport) return 0;
|
||||
const result = await bridge.aiSessionsBackupImport();
|
||||
if (!result?.success || !result.json) return 0;
|
||||
const imported = parseBackupSessions(result.json);
|
||||
if (!imported || imported.length === 0) return 0;
|
||||
// Merge synchronously against the live ref so orphan cleanup sees the
|
||||
// imported chats even if React would defer a setState updater.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = persisted;
|
||||
setLatestAISessionsSnapshot(persisted);
|
||||
setSessionsRaw(persisted);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Import does not change live terminals/workspaces, so the orphan-cleanup
|
||||
// effect will not run. Reuse the last known live target set (empty is
|
||||
// valid: nothing live) to mark chats bound to missing scopes as dangling
|
||||
// so /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? persisted;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return persisted.filter(session => importedIds.has(session.id)).length;
|
||||
}, []);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
@@ -1370,6 +1544,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -1432,6 +1609,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
|
||||
import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
|
||||
|
||||
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
active: boolean;
|
||||
context: Record<string, unknown>;
|
||||
adapter: ViewAdapter;
|
||||
subscribeHidden(listener: () => void): () => void;
|
||||
unavailable(): void;
|
||||
focused?(): void;
|
||||
focusOnReveal?: boolean;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const adapterRef = useRef(adapter);
|
||||
const unavailableRef = useRef(unavailable);
|
||||
const focusedRef = useRef(focused);
|
||||
const revealFocusRef = useRef(focusOnReveal);
|
||||
const requestFocusRef = useRef<() => void>(() => {});
|
||||
revealFocusRef.current = focusOnReveal;
|
||||
focusedRef.current = focused;
|
||||
adapterRef.current = adapter;
|
||||
unavailableRef.current = unavailable;
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!active || !mount) return;
|
||||
let cancelled = false;
|
||||
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
|
||||
const instanceId = crypto.randomUUID();
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
|
||||
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
lifecycle.markViewClosed(viewId);
|
||||
void closeView(instanceId).catch(() => {});
|
||||
};
|
||||
const failCurrent = () => {
|
||||
if (cancelled) return;
|
||||
close();
|
||||
unavailableRef.current();
|
||||
};
|
||||
const unsubscribe = onViewClosed((event) => {
|
||||
lifecycle.handleHostClose(event.instanceId);
|
||||
if (event.instanceId === instanceId && !cancelled) {
|
||||
close();
|
||||
unavailableRef.current();
|
||||
}
|
||||
});
|
||||
const stopOnHide = subscribeHidden(close);
|
||||
const stopOnFocus = onViewFocused((event) => {
|
||||
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
|
||||
});
|
||||
const bounds = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
|
||||
};
|
||||
let opened = false;
|
||||
let lastBounds = '';
|
||||
let visible = false;
|
||||
const focus = () => {
|
||||
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
|
||||
};
|
||||
requestFocusRef.current = focus;
|
||||
const updateVisibility = () => {
|
||||
if (!opened || cancelled) return;
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) {
|
||||
const next = bounds();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
|
||||
}
|
||||
updateVisibility();
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
|
||||
observer?.observe(mount);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('scroll', resize, true);
|
||||
setLoading(true);
|
||||
const opening = lifecycle.beginOpen({ viewKey: viewId });
|
||||
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
|
||||
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
|
||||
await closeView(result.instanceId);
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
|
||||
resize();
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
|
||||
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
|
||||
};
|
||||
}, [active, context, slot, subscribeHidden, version, viewId]);
|
||||
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
|
||||
return { mountRef, loading };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
|
||||
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
|
||||
|
||||
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
|
||||
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
|
||||
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
|
||||
subscribeHidden(listener: () => void): () => void; unavailable(): void;
|
||||
}) {
|
||||
const contextKey = JSON.stringify(context);
|
||||
const key = JSON.stringify([viewId, version, slot, contextKey]);
|
||||
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
|
||||
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) { unavailableRef.current(); return; }
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
try {
|
||||
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
|
||||
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
|
||||
const stop = session.subscribe(update); update(); setHidden(false);
|
||||
sessionRef.current = { key, send: session.send, image: session.image };
|
||||
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
|
||||
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
|
||||
} catch { unavailableRef.current(); }
|
||||
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
|
||||
const loadImage = useCallback((path: string) => {
|
||||
const session = sessionRef.current;
|
||||
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
|
||||
}, [active, key]);
|
||||
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
|
||||
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
|
||||
}, [active, key]);
|
||||
// A newly selected plugin must never receive values from the previous tree,
|
||||
// even during the render before effect cleanup and the next activation.
|
||||
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { buildMonitorDiskListLayout, type MonitorDiskListStats } from "../../domain/monitorDisks";
|
||||
|
||||
/** Volatile per-host disclosure preference; capacity alerts always force it open. */
|
||||
export function useMonitorDiskGroups() {
|
||||
const [expandedHosts, setExpandedHosts] = useState<ReadonlySet<string>>(() => new Set());
|
||||
return {
|
||||
layoutFor: (hostId: string, stats?: MonitorDiskListStats | null) =>
|
||||
buildMonitorDiskListLayout(stats, expandedHosts.has(hostId)),
|
||||
toggle: (hostId: string) => setExpandedHosts(previous => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(hostId)) next.delete(hostId);
|
||||
else next.add(hostId);
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const latest = useRef({ activeTabId, sessions, workspaces });
|
||||
latest.current = { activeTabId, sessions, workspaces };
|
||||
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
|
||||
const [noteId, setNoteId] = useState('');
|
||||
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
|
||||
const bridge = {
|
||||
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
|
||||
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
|
||||
respondPluginAIHost: api.respondPluginAIHost.bind(api),
|
||||
};
|
||||
let selectedNoteId: string | null = null;
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
const finish = (id: string | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
|
||||
selectedNoteId = id || null;
|
||||
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
|
||||
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel };
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
setNoteId('');
|
||||
// This inventory stays inside trusted host UI. The plugin receives
|
||||
// summaries only for the Note chosen here and current selected scopes.
|
||||
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
|
||||
setChoice({ confirm: (id) => finish(id), cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
requests.get(String(request.payload.requestId))?.controller.abort();
|
||||
return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId);
|
||||
return;
|
||||
}
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController();
|
||||
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
|
||||
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
|
||||
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
void bridge.setPluginAIHostReady(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
for (const pending of requests.values()) pending.controller.abort();
|
||||
requests.clear(); runtime.shutdown();
|
||||
void bridge.setPluginAIHostReady(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice, noteId, setNoteId, noteChoices };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
|
||||
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
|
||||
|
||||
// All inventory remains in trusted application UI. Only confirmed metadata is
|
||||
// passed to the main-owned worker bindings; focus never determines a target.
|
||||
export function usePluginAgentHost(sessions: TerminalSession[]) {
|
||||
const latest = useRef(sessions); latest.current = sessions;
|
||||
const [choice, setChoice] = useState<Choice | null>(null);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
|
||||
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAgentRuntime({
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A target selection is already pending')); return; }
|
||||
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
|
||||
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
|
||||
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
|
||||
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
|
||||
}));
|
||||
const finish = (ids: string[] | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
|
||||
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
|
||||
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
|
||||
reject(Error('Selected terminal is no longer connected')); return;
|
||||
}
|
||||
resolve(selected as PluginAgentHostTerminal[]);
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
|
||||
setChoice({ items, confirm: finish, cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.subscribe((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
const pending = requests.get(request.payload.requestId);
|
||||
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request, controller.signal).then(
|
||||
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
|
||||
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
|
||||
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
|
||||
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
|
||||
const configurationChanged = (event: Event) => {
|
||||
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (key === null || (key && configurationKeys.has(key))) {
|
||||
runtime.invalidateConfiguration();
|
||||
// Invalidate main-owned backend leases too, even with no event read in
|
||||
// flight. Re-advertising availability does not renew existing handles.
|
||||
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
void bridge.ready(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
|
||||
void bridge.ready(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// All visible rows share one IPC subscription. No catalog fetch or polling here.
|
||||
const generations = new Map<string, number>();
|
||||
const listeners = new Set<() => void>();
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
|
||||
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
|
||||
for (const notify of listeners) notify();
|
||||
});
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
|
||||
}) {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
|
||||
const pluginId = plugin?.id;
|
||||
const entryId = entry?.id;
|
||||
const selectionToken = entry?.selectionToken;
|
||||
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
|
||||
? { kind: 'installed', pluginId }
|
||||
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
|
||||
[pluginId, entryId, selectionToken, sourceId]);
|
||||
const identitySource = plugin?.source?.id ?? sourceId;
|
||||
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
|
||||
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
|
||||
const installed = Boolean(plugin);
|
||||
const version = plugin?.activeVersion;
|
||||
const archiveSha256 = plugin?.archiveSha256 ?? null;
|
||||
const installedSourceId = plugin?.source?.id ?? 'local';
|
||||
useEffect(() => {
|
||||
if (!element || !request) return;
|
||||
let active = true;
|
||||
let started = false;
|
||||
const load = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
|
||||
if (!active || !value) return;
|
||||
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
|
||||
setLoaded({ key, value });
|
||||
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
|
||||
};
|
||||
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
|
||||
}, { rootMargin: '64px' });
|
||||
if (observer) observer.observe(element); else load();
|
||||
return () => { active = false; observer?.disconnect(); };
|
||||
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
|
||||
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
|
||||
const [listAttempt, setListAttempt] = useState(0);
|
||||
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [imageState, setImageState] = useState<{ id: string; token?: string; sourceId: string; images: Record<string, string> } | null>(null);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
|
||||
@@ -51,9 +52,19 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (selectedId && selectionToken) {
|
||||
void netcattyBridge.get()?.getPluginCatalogReadmeImages?.(selectedId, selectionToken, sourceId)
|
||||
.then((images) => { if (active) setImageState({ id: selectedId, token: selectionToken, sourceId, images }); })
|
||||
.catch(() => { if (active) setImageState(null); });
|
||||
}
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, sourceId, readmeAttempt]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source, sourceBinding,
|
||||
readmeImages: imageState?.id === selectedId && imageState.token === selectionToken && imageState.sourceId === sourceId ? imageState.images : {},
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
|
||||
@@ -86,14 +86,8 @@ export interface UsePluginContributionsResult {
|
||||
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
|
||||
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
|
||||
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
|
||||
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
|
||||
closeView(instanceId: string): Promise<void>;
|
||||
focusView(instanceId: string): Promise<void>;
|
||||
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
|
||||
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
|
||||
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
}
|
||||
|
||||
export function usePluginContributions(
|
||||
@@ -189,32 +183,6 @@ export function usePluginContributions(
|
||||
return picker(title, defaultPath);
|
||||
}, [bridge]);
|
||||
|
||||
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
|
||||
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
|
||||
return bridge.openPluginView(payload);
|
||||
}, [bridge]);
|
||||
|
||||
const closeView = useCallback(async (instanceId: string) => {
|
||||
if (!bridge?.closePluginView) return;
|
||||
await bridge.closePluginView(instanceId);
|
||||
}, [bridge]);
|
||||
const focusView = useCallback(async (instanceId: string) => {
|
||||
await bridge?.focusPluginView?.({ instanceId });
|
||||
}, [bridge]);
|
||||
|
||||
const setViewBounds = useCallback(async (
|
||||
instanceId: string,
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
) => {
|
||||
if (!bridge?.setPluginViewBounds) return;
|
||||
await bridge.setPluginViewBounds(instanceId, bounds);
|
||||
}, [bridge]);
|
||||
|
||||
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
|
||||
if (!bridge?.setPluginViewVisibility) return;
|
||||
await bridge.setPluginViewVisibility(instanceId, visible);
|
||||
}, [bridge]);
|
||||
|
||||
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
|
||||
if (!bridge?.setPluginEnvironment) return;
|
||||
await bridge.setPluginEnvironment(environment);
|
||||
@@ -223,10 +191,6 @@ export function usePluginContributions(
|
||||
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
|
||||
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
|
||||
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
|
||||
const currentLoadState = resolvePluginContributionLoadState({
|
||||
currentQueryKey: queryKey,
|
||||
loadedQueryKey: loadedSnapshot.queryKey,
|
||||
@@ -245,13 +209,7 @@ export function usePluginContributions(
|
||||
updateSetting,
|
||||
resetSetting,
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function usePluginHostActions() {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
setReview({ ...entry });
|
||||
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,12 @@ export function usePluginHostActions() {
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
|
||||
setReview(null);
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
|
||||
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
|
||||
} else setReview(entry);
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
@@ -137,7 +142,7 @@ export function usePluginHostActions() {
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, getVault]);
|
||||
}, [execute, fail, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
@@ -19,8 +20,17 @@ export function usePluginHostInteractions() {
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
|
||||
current.current = event as UiPrompt;
|
||||
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
|
||||
setPrompt(null);
|
||||
try {
|
||||
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
|
||||
});
|
||||
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
|
||||
} else setPrompt(event as UiPrompt);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
});
|
||||
@@ -38,8 +48,11 @@ export function usePluginHostInteractions() {
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
|
||||
const selection = action === 'preview' && active.request.kind === 'terminal.selection.read'
|
||||
? readPluginTerminalSelection(active.terminalSessionId ?? '', active.terminalRendererId ?? '') : undefined;
|
||||
const accepted = await bridge.respondPluginInteraction({
|
||||
requestId: active.requestId, actionToken: active.actionToken, action,
|
||||
...(selection === undefined ? {} : { value: selection }),
|
||||
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
|
||||
});
|
||||
if (!accepted && current.current?.actionToken === active.actionToken) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginLogs() {
|
||||
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
|
||||
: action === 'open' ? bridge.openPluginLogFolder?.()
|
||||
: action === 'export' ? bridge.exportPluginLogs?.()
|
||||
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
|
||||
if (!result) throw new Error('Local plugin logging is unavailable');
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (cause) {
|
||||
let message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (action === 'configure') {
|
||||
// Configuration fails closed before disk I/O. Show actual host state,
|
||||
// even when persisting a setting failed, rather than keeping stale ON.
|
||||
try {
|
||||
const actual = await netcattyBridge.require().getPluginLogStatus?.();
|
||||
if (!actual) throw new Error('Local plugin log status is unavailable');
|
||||
if (mounted.current) setStatus(actual);
|
||||
} catch (refreshError) {
|
||||
if (mounted.current) setStatus(null);
|
||||
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
|
||||
}
|
||||
}
|
||||
if (mounted.current) setError(message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void run('status');
|
||||
return () => { mounted.current = false; };
|
||||
}, [run]);
|
||||
return { status, error, busy, run };
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export function usePluginManager() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
|
||||
if (options?.clearError) setError(null);
|
||||
const current = ++generation.current;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -37,18 +38,20 @@ export function usePluginManager() {
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let failure: Error | null = null;
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
if (throwOnError && failure) throw failure;
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
@@ -69,6 +72,6 @@ export function usePluginManager() {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}),
|
||||
}, true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginPermissionPrompt() {
|
||||
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
|
||||
const prompt = prompts[0];
|
||||
active.current = prompt ?? null;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
|
||||
const unsubscribe = bridge.onPluginPermission((event) => {
|
||||
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
|
||||
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
|
||||
setBusy(false);
|
||||
});
|
||||
void bridge.setPluginPermissionReady(true).catch(() => {});
|
||||
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
|
||||
}, []);
|
||||
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
|
||||
if (!prompt || busy || active.current !== prompt) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
|
||||
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
|
||||
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { prompt, busy, respond };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
|
||||
|
||||
export function usePluginSettingsModel(pluginId: string) {
|
||||
const { locale } = useI18n();
|
||||
const catalog = usePluginSettingScopeCatalog();
|
||||
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
|
||||
useEffect(() => {
|
||||
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
|
||||
}, [catalog]);
|
||||
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
|
||||
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
|
||||
}), [locale, scopeIds]);
|
||||
const contributions = usePluginContributions(query);
|
||||
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
|
||||
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
|
||||
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
|
||||
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
|
||||
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { getTerminalSelectionForClipboard } from '../../components/terminal/normalizeTerminalSelection';
|
||||
import { publishTerminalSelectionTarget, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
|
||||
type SelectionState = {
|
||||
sessionId: string | null;
|
||||
bootEpoch: number;
|
||||
term: Terminal | null;
|
||||
visible: boolean;
|
||||
isFocusedPane: boolean | undefined;
|
||||
connected: boolean;
|
||||
sensitive: boolean;
|
||||
normalize: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// No retained snapshots, PTY adapter, recorder, or AI context reader enters here.
|
||||
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
|
||||
const target = () => {
|
||||
const state = readState();
|
||||
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
|
||||
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|
||||
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
|
||||
return { sessionId: state.sessionId, rendererId: `${instanceId}:${state.bootEpoch}`, label: state.label.slice(0, 160) || 'Terminal' };
|
||||
};
|
||||
return {
|
||||
target,
|
||||
read() {
|
||||
if (!target()) throw new Error('The selected terminal is unavailable');
|
||||
const { term, normalize } = readState();
|
||||
const range = term!.getSelectionPosition();
|
||||
// Refuse a large range before asking xterm to materialize its text.
|
||||
if (!range || Math.abs(range.end.y - range.start.y) * term!.cols + Math.abs(range.end.x - range.start.x) > 4096) {
|
||||
throw new Error('Select a bounded terminal range first');
|
||||
}
|
||||
return getTerminalSelectionForClipboard(term!, normalize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginTerminalSelection(key: string, readState: () => SelectionState, lifecycle: string) {
|
||||
const latest = useRef(readState);
|
||||
latest.current = readState;
|
||||
const instance = useRef<string | null>(null);
|
||||
if (instance.current === null) instance.current = crypto.randomUUID();
|
||||
useEffect(() => registerTerminalSelectionSource(key, createPluginTerminalSelectionSource(() => latest.current(), instance.current!)), [key]);
|
||||
// The target getter reads current refs. Publication revokes old renderer/boot identities.
|
||||
useEffect(() => { publishTerminalSelectionTarget(); }, [lifecycle]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
/** Manual catalog checks only; no scheduler, install or permission mutation. */
|
||||
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
|
||||
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const generation = useRef(0);
|
||||
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
|
||||
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
|
||||
plugin.source?.kind, plugin.source?.origin,
|
||||
])), [plugins]);
|
||||
useEffect(() => {
|
||||
generation.current++;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
const requests = generation;
|
||||
return () => { requests.current++; };
|
||||
}, [identityKey]);
|
||||
useEffect(() => {
|
||||
const invalidate = () => {
|
||||
generation.current++;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
};
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
|
||||
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
|
||||
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
|
||||
}, []);
|
||||
const check = useCallback(async () => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
const current = generation.current;
|
||||
setBusy(true); setError(null); setResult(null);
|
||||
try {
|
||||
const value = await checkPluginUpdates(netcattyBridge.require());
|
||||
if (mounted.current && current === generation.current) setResult(value);
|
||||
} catch (cause) {
|
||||
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
// This component may still be mounted with a refreshed package list.
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
return { result, error, busy, check };
|
||||
}
|
||||
@@ -14,17 +14,11 @@ import {
|
||||
usePluginViewTabs,
|
||||
} from './pluginViewTabStore';
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
reconcilePluginViewTabCatalog,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
type HostedPluginViewState,
|
||||
type PluginViewSnapshotSelection,
|
||||
} from './pluginViewLifecycle';
|
||||
import {
|
||||
canRetainPluginViewInScope,
|
||||
resolvePluginRetainedViewKey,
|
||||
resolvePluginViewWindowScope,
|
||||
} from './pluginViewScopes';
|
||||
import { pluginViewSessions } from './pluginViewSession';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
|
||||
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
|
||||
@@ -69,17 +63,8 @@ export function usePluginViewLifecycle({
|
||||
const {
|
||||
snapshot,
|
||||
executeCommand,
|
||||
openView,
|
||||
closeView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
} = contributions;
|
||||
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
|
||||
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
|
||||
const closeViewRef = useRef(closeView);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
|
||||
@@ -92,16 +77,6 @@ export function usePluginViewLifecycle({
|
||||
requestedViewId: effectiveRequested?.viewId,
|
||||
contextKey: viewQueryContextKey,
|
||||
});
|
||||
const activeViewId = activeView?.view.id;
|
||||
const viewScopeId = typeof window === 'undefined'
|
||||
? 'window:server'
|
||||
: resolvePluginViewWindowScope(window.location);
|
||||
const retainedViewKey = activeViewId
|
||||
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
|
||||
: null;
|
||||
|
||||
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewContributions.loading) return;
|
||||
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
|
||||
@@ -113,26 +88,6 @@ export function usePluginViewLifecycle({
|
||||
: null;
|
||||
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
|
||||
|
||||
useEffect(() => onViewClosed((event) => {
|
||||
const next = lifecycleRef.current.handleHostClose(event.instanceId);
|
||||
if (next.matchedCurrent) {
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
}
|
||||
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
|
||||
}), [onViewClosed]);
|
||||
|
||||
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
|
||||
const next = lifecycleRef.current.handleTabClose(tab.id);
|
||||
if (next.matchedCurrent) {
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
}
|
||||
for (const instanceId of next.instanceIds) {
|
||||
void closeViewRef.current(instanceId).catch(() => {});
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
|
||||
.filter((binding) => binding.enabled)
|
||||
@@ -181,153 +136,16 @@ export function usePluginViewLifecycle({
|
||||
setRequested(null);
|
||||
}, [activeView, requested]);
|
||||
|
||||
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
|
||||
if (!current.retainContextWhenHidden) {
|
||||
await closeView(current.id);
|
||||
return;
|
||||
}
|
||||
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
|
||||
lifecycleRef.current.retain(key, current);
|
||||
try {
|
||||
await setViewVisibility(current.id, false);
|
||||
} catch {
|
||||
lifecycleRef.current.removeRetained(key);
|
||||
await closeView(current.id);
|
||||
}
|
||||
}, [closeView, setViewVisibility]);
|
||||
useEffect(() => {
|
||||
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
|
||||
return () => { stop(); };
|
||||
}, []);
|
||||
|
||||
const close = useCallback(async () => {
|
||||
lifecycleRef.current.markViewClosed(retainedViewKey);
|
||||
const current = lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
if (current) await closeView(current.id);
|
||||
}, [closeView, retainedViewKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const removed = lifecycleRef.current.removeRetainedWhere(
|
||||
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
|
||||
);
|
||||
for (const retained of removed) void closeView(retained.id).catch(() => {});
|
||||
}, [closeView, viewScopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
if (effectiveRequested?.viewId === instance.viewId
|
||||
&& activeViewId === instance.viewId
|
||||
&& instance.scopeId === viewScopeId) return;
|
||||
lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
|
||||
void hideOrClose(instance);
|
||||
} else {
|
||||
void closeView(instance.id).catch(() => {});
|
||||
}
|
||||
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
|
||||
let cancelled = false;
|
||||
const openingTabId = activePluginTab?.id;
|
||||
const openingToken = lifecycleRef.current.beginOpen({
|
||||
viewKey: retainedViewKey,
|
||||
tabId: openingTabId,
|
||||
label: activePluginTab?.id ?? activeViewId,
|
||||
});
|
||||
const bounds = mountRef.current.getBoundingClientRect();
|
||||
const nextBounds = {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(1, Math.round(bounds.width)),
|
||||
height: Math.max(1, Math.round(bounds.height)),
|
||||
};
|
||||
void (async () => {
|
||||
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
|
||||
if (opened) {
|
||||
try {
|
||||
await setViewBounds(opened.id, nextBounds);
|
||||
await setViewVisibility(opened.id, true);
|
||||
} catch {
|
||||
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
|
||||
opened = null;
|
||||
}
|
||||
}
|
||||
if (!opened) {
|
||||
const result = await openView({
|
||||
viewId: activeViewId,
|
||||
scopeId: viewScopeId,
|
||||
bounds: nextBounds,
|
||||
context: effectiveRequested?.context,
|
||||
});
|
||||
opened = {
|
||||
id: result.instanceId,
|
||||
viewId: activeViewId,
|
||||
scopeId: viewScopeId,
|
||||
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
|
||||
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
|
||||
};
|
||||
}
|
||||
if (lifecycleRef.current.consumeHostClose(opened.id)) {
|
||||
throw new Error('Plugin view closed while its open response was in flight');
|
||||
}
|
||||
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
|
||||
await closeView(opened.id);
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
await hideOrClose(opened);
|
||||
return;
|
||||
}
|
||||
lifecycleRef.current.setCurrent(opened);
|
||||
setInstance(opened);
|
||||
})().catch(() => {
|
||||
if (cancelled) return;
|
||||
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
|
||||
else setRequested(null);
|
||||
}).finally(() => {
|
||||
lifecycleRef.current.finishOpen({
|
||||
token: openingToken,
|
||||
viewKey: retainedViewKey,
|
||||
tabId: openingTabId,
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activePluginTab,
|
||||
activeView?.view.retainContextWhenHidden,
|
||||
activeViewId,
|
||||
closeView,
|
||||
effectiveRequested?.context,
|
||||
hideOrClose,
|
||||
instance,
|
||||
openView,
|
||||
retainedViewKey,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
viewScopeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance || !mountRef.current) return;
|
||||
const update = () => {
|
||||
const bounds = mountRef.current?.getBoundingClientRect();
|
||||
if (!bounds) return;
|
||||
void setViewBounds(instance.id, {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(1, Math.round(bounds.width)),
|
||||
height: Math.max(1, Math.round(bounds.height)),
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(mountRef.current);
|
||||
window.addEventListener('resize', update);
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [instance, setViewBounds]);
|
||||
if (activeView) pluginViewSessions.closeView(activeView.view.id);
|
||||
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
|
||||
}, [activePluginTab, activeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contributions.available) return;
|
||||
@@ -364,16 +182,10 @@ export function usePluginViewLifecycle({
|
||||
};
|
||||
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const views = lifecycleRef.current.drain();
|
||||
const ids = new Set(views.map((view) => view.id));
|
||||
for (const id of ids) void closeViewRef.current(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeView,
|
||||
close,
|
||||
effectiveRequested,
|
||||
mountRef,
|
||||
viewQueryContext,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
diskReadSpeed?: number | null; // Mounted block-device I/O, bytes/sec
|
||||
diskWriteSpeed?: number | null;
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
|
||||
mountPoint: string;
|
||||
|
||||
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
invertSplitPosition,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
@@ -795,12 +796,68 @@ export const useSessionState = ({
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Merge two tabs into one workspace. When one of them already belongs to a
|
||||
// workspace, that workspace (and its id) is reused instead of dissolving
|
||||
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
||||
// regenerating it used to strand every conversation anchored to it
|
||||
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
|
||||
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
|
||||
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
|
||||
if (anchorWorkspaceId) {
|
||||
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
|
||||
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
|
||||
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
|
||||
if (!orphanSession || orphanSession.workspaceId) return;
|
||||
// Split next to the anchor's existing pane: the raw drop hint targets
|
||||
// the orphan's pane, which is not part of this workspace tree yet.
|
||||
// When the dragged (joining) pane already owns the workspace, the orphan
|
||||
// is what gets inserted — invert the drop position so the previewed
|
||||
// side still matches (hint describes where the dragged pane should sit).
|
||||
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
|
||||
const insertHint = {
|
||||
...hint,
|
||||
targetSessionId: anchorSessionId,
|
||||
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
|
||||
};
|
||||
addSessionToWorkspace(
|
||||
anchorWorkspaceId,
|
||||
orphanSessionId,
|
||||
insertHint,
|
||||
);
|
||||
// The orphan's standalone tab slot is replaced by the workspace tab.
|
||||
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
|
||||
return;
|
||||
}
|
||||
// Name the merged workspace after both tabs' connection labels (e.g.
|
||||
// "01/02") so the window title and the rename dialog show the merged
|
||||
// identity instead of the generic "Workspace". Falls back to the default
|
||||
@@ -855,30 +912,7 @@ export const useSessionState = ({
|
||||
return s;
|
||||
});
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [addSessionToWorkspace, setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { collectSessionIds } from "../../domain/workspace.ts";
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let baseId = "";
|
||||
let memberId = "";
|
||||
let standaloneId = "";
|
||||
await act(async () => {
|
||||
baseId = current().createLocalTerminal({ shellName: "01" });
|
||||
memberId = current().createLocalTerminal({ shellName: "02" });
|
||||
standaloneId = current().createLocalTerminal({ shellName: "03" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(baseId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: baseId,
|
||||
});
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
|
||||
|
||||
// Dropping the standalone tab onto a member of the workspace must join the
|
||||
// existing workspace — regenerating the id would strand AI chats bound to it.
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(standaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: standaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 1);
|
||||
assert.equal(current().workspaces[0]?.id, workspaceId);
|
||||
assert.equal(
|
||||
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
|
||||
workspaceId,
|
||||
);
|
||||
const memberIds = collectSessionIds(current().workspaces[0]!.root);
|
||||
assert.ok(memberIds.includes(standaloneId));
|
||||
assert.ok(memberIds.includes(memberId));
|
||||
// The standalone tab slot is replaced by the workspace tab.
|
||||
assert.ok(!current().orderedTabs.includes(standaloneId));
|
||||
|
||||
|
||||
// Dropping a workspace member onto a standalone tab must insert the
|
||||
// standalone on the opposite side of the drop hint (hint describes where
|
||||
// the dragged member should appear relative to the standalone).
|
||||
let orientationStandaloneId = "";
|
||||
let orientationMemberId = "";
|
||||
await act(async () => {
|
||||
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
|
||||
});
|
||||
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
|
||||
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: orientationStandaloneId,
|
||||
});
|
||||
});
|
||||
{
|
||||
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
|
||||
assert.ok(root);
|
||||
const ordered = collectSessionIds(root!);
|
||||
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
|
||||
const memberIdx = ordered.indexOf(memberId);
|
||||
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
|
||||
assert.ok(
|
||||
standaloneIdx < memberIdx,
|
||||
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Two genuinely standalone tabs still create a fresh workspace.
|
||||
let firstStandaloneId = "";
|
||||
let secondStandaloneId = "";
|
||||
await act(async () => {
|
||||
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
|
||||
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: firstStandaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 2);
|
||||
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
|
||||
assert.ok(freshWorkspace);
|
||||
assert.notEqual(freshWorkspace.id, workspaceId);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -645,6 +646,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
windowOpacityMutationSourceRef.current = 'local';
|
||||
setWindowOpacityRecord(toggleWindowOpacityRecord);
|
||||
}, []);
|
||||
const [windowBackgroundBlur, setWindowBackgroundBlur] = useState<boolean>(() => {
|
||||
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
|
||||
});
|
||||
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
|
||||
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
|
||||
@@ -1276,6 +1280,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -1298,12 +1303,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
|
||||
// Detached sessions share opacity and the shortcut configuration needed to
|
||||
// operate it. Keep general settings listeners and OS effects disabled there.
|
||||
// Detached sessions share window chrome (opacity, background blur) and the
|
||||
// shortcut configuration needed to operate it. Keep general settings
|
||||
// listeners and OS effects disabled there.
|
||||
useEffect(() => {
|
||||
if (enableSettingsSync) return;
|
||||
const applySharedWindowSetting = (key: string, value: unknown) => {
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
|
||||
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) {
|
||||
// Storage events deliver the raw string; IPC delivers a boolean.
|
||||
const incoming = typeof value === 'boolean' ? value : value === 'true';
|
||||
setWindowBackgroundBlur(incoming);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -1377,7 +1388,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -1387,7 +1398,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -1896,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -2276,6 +2288,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
toggleWindowOpacity,
|
||||
windowBackgroundBlur,
|
||||
setWindowBackgroundBlur,
|
||||
appIconVariant,
|
||||
setAppIconVariant,
|
||||
rehydrateAllFromStorage,
|
||||
|
||||
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
|
||||
contextViewportSnapshot: string;
|
||||
contextScrollbackSnapshot: string;
|
||||
alternateScreen: boolean;
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
|
||||
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function harness() {
|
||||
const requests: ReturnType<typeof deferred<unknown>>[] = [];
|
||||
const events = new EventTarget();
|
||||
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
|
||||
window: {
|
||||
netcatty: {
|
||||
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
|
||||
},
|
||||
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
|
||||
},
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
let result!: ToolAccessGuidanceState;
|
||||
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(<Harness mode="mcp" />); });
|
||||
return {
|
||||
requests, events, values, result: () => result,
|
||||
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
|
||||
close: async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
},
|
||||
};
|
||||
}
|
||||
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
|
||||
|
||||
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
assert.equal(h.requests.length, 1);
|
||||
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(status('/fixture/new')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/old')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.skills();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result().mcpLauncherPath, null);
|
||||
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
|
||||
assert.equal(h.result().commandPrefix, 'fixture-cli');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
|
||||
const h = await harness();
|
||||
const initial = h.result();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result(), initial);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
|
||||
* runtime is still starting or after an idle shutdown).
|
||||
*/
|
||||
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
|
||||
const requestGeneration = useRef(0);
|
||||
const [skillPath, setSkillPath] = useState<string | null>(null);
|
||||
const [commandPrefix, setCommandPrefix] = useState('');
|
||||
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
|
||||
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
|
||||
|
||||
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
|
||||
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
|
||||
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
|
||||
if (!status?.ok) return undefined;
|
||||
if (!isCurrent() || !status?.ok) return undefined;
|
||||
// Do not trust `enabled` alone: the main process flips `enabled` before
|
||||
// host credentials are resolved and the discovery file is written, and it
|
||||
// keeps reporting the configured discovery/launcher paths while starting.
|
||||
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
|
||||
|
||||
const refetch = async () => {
|
||||
if (cancelled) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
const isCurrent = () => !cancelled && generation === requestGeneration.current;
|
||||
const storedEnabled = readExternalMcpStoredEnabled();
|
||||
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
|
||||
if (cancelled) return;
|
||||
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
|
||||
if (!isCurrent()) return;
|
||||
// The persisted switch is off but the status check failed: drop any
|
||||
// cached launcher/discovery paths. The stored credentials were revoked
|
||||
// by the disable, and falling through to the comparison below would see
|
||||
|
||||
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
|
||||
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
|
||||
|
||||
useEffect(() => {
|
||||
// Defaults can include dynamically discovered items; re-read without writing
|
||||
// so temporary catalog changes never erase stored toolbar preferences.
|
||||
setLayout(readLayout(storageKey, defaults));
|
||||
const sync = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== storageKey) return;
|
||||
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
// Exercise actual vault save/load/JSON import and sync boundaries, including
|
||||
// the asynchronous encrypted-field adapter (without a native safeStorage key).
|
||||
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
|
||||
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
|
||||
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
const mount = async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
await act(async () => { await state!.readPersistedHosts(); });
|
||||
};
|
||||
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
try {
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true]]);
|
||||
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
|
||||
if (created.ok === false) throw Error(created.error);
|
||||
assert.equal(created.ok, true);
|
||||
const standard = { ...created.host, id: "standard" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
|
||||
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
|
||||
// A later deliberate false must survive migration, including another boot.
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
|
||||
const expected = [["legacy", false], ["standard", false], ["copy", true]];
|
||||
const exported = JSON.stringify(state!.exportData());
|
||||
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
// Use the real sync payload builder and apply importer, not JSON alone.
|
||||
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), expected);
|
||||
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
|
||||
// Older cloud snapshots enter through the same per-record migration.
|
||||
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
|
||||
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), [["cloud-legacy", true]]);
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false]]);
|
||||
// These drafts enter HostDetailsPanel as initialData, then the real vault
|
||||
// save/normalization path. They must not be mistaken for legacy records.
|
||||
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
|
||||
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
|
||||
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
|
||||
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
|
||||
type: "draft" as const, draft: { kind: "host" as const, value: {
|
||||
hostname: `import-${choice}.test`, protocol: "ssh",
|
||||
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
|
||||
} },
|
||||
})));
|
||||
assert.deepEqual(imported.errors, []);
|
||||
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
|
||||
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
|
||||
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
test("shell history append yields the input turn and persists merged entries", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
const entry = { command: " echo hello ", hostId: "local", hostLabel: "Local", sessionId: "one" };
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, sessionId: "two" });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY), null);
|
||||
assert.equal(state!.shellHistory.length, 0);
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].command, "echo hello");
|
||||
assert.equal(saved[0].sessionId, "two");
|
||||
assert.deepEqual(state!.shellHistory, saved);
|
||||
|
||||
for (const clear of [() => state!.clearShellHistory(), () => state!.clearAllCommandHistory()]) {
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, command: "another command" });
|
||||
clear();
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
assert.deepEqual(state!.shellHistory, []);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!), []);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -317,6 +317,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const [noteGroups, setNoteGroups] = useState<string[]>([]);
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const shellHistoryGenerationRef = useRef(0);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
|
||||
@@ -1445,19 +1446,25 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
// Persist immediately so crash between commit and layout effect cannot drop history.
|
||||
// Store republish is also done in useLayoutEffect for load/storage-event paths.
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
const generation = shellHistoryGenerationRef.current;
|
||||
// Yield the input turn before history rendering and synchronous storage work.
|
||||
// Persist in the next task to keep the crash-loss window small.
|
||||
setTimeout(() => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return;
|
||||
setShellHistory((prev) => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return prev;
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearShellHistory = useCallback(() => {
|
||||
shellHistoryGenerationRef.current += 1;
|
||||
setShellHistory([]);
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
|
||||
publishShellHistorySnapshot([]);
|
||||
@@ -1582,6 +1589,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
|
||||
@@ -208,13 +208,15 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
|
||||
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
|
||||
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
|
||||
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
|
||||
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
|
||||
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
|
||||
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
|
||||
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
|
||||
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
|
||||
wait.mock.restore();
|
||||
globalThis.localStorage.setItem = original;
|
||||
await act(async () => { hook!.setStreamingForScope('chat', false); });
|
||||
@@ -226,6 +228,44 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
}
|
||||
});
|
||||
|
||||
test('cloud sync preserves an active trimmed conversation and its native thread', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let hook: UseAIChatStreamingReturn;
|
||||
function Harness() {
|
||||
hook = useAIChatStreaming({ maxIterations: 1, addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {} });
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
const calls: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const messages = Array.from({ length: 501 }, (_, i) => ({ id: `m-${i}`, role: 'user' as const, content: `Message ${i}`, timestamp: i }));
|
||||
const remote = { ...session(), agentId: 'codex', messages };
|
||||
// Appending the 501st message trims the oldest message locally.
|
||||
const local = { ...remote, messages: messages.slice(-500), externalSessionId: 'native-thread' };
|
||||
save([local]);
|
||||
hook!.abortControllersRef.current.set('chat', controller);
|
||||
await act(async () => { hook!.setStreamingForScope('chat', true); });
|
||||
Object.assign(window, { netcatty: {
|
||||
aiCattyCancelExec: async () => { calls.push('cancel'); },
|
||||
aiSdkAgentCancel: async () => { calls.push('sdk-cancel'); return { ok: true }; },
|
||||
aiSdkAgentCleanup: async () => { calls.push('cleanup'); return { ok: true }; },
|
||||
} });
|
||||
try {
|
||||
for (const incoming of [[remote], []]) {
|
||||
await applySyncPayload(remotePayload(incoming), { importVaultData: () => undefined });
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
|
||||
}
|
||||
} finally {
|
||||
hook!.abortControllersRef.current.delete('chat');
|
||||
await act(async () => { hook!.setStreamingForScope('chat', false); renderer!.unmount(); });
|
||||
delete (window as unknown as { netcatty?: unknown }).netcatty;
|
||||
}
|
||||
});
|
||||
|
||||
test('local restore replaces later sessions and later turns with the backup snapshot', async () => {
|
||||
const backedUp = session();
|
||||
const later = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
|
||||
@@ -1275,6 +1275,8 @@ export async function buildLocalVaultPayloadAsync(
|
||||
|
||||
/** Merge downloaded chat data through the existing state and persistence boundary. */
|
||||
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
|
||||
const { isAIChatSessionStreaming, stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
|
||||
const preserveActive = (id: string) => !includeLocalOnlyData && isAIChatSessionStreaming(id);
|
||||
const incoming = includeLocalOnlyData
|
||||
? sessions
|
||||
: sessions.map(stripAiSessionDeviceState);
|
||||
@@ -1282,26 +1284,29 @@ async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions'
|
||||
const incomingById = new Map(incoming.map((session) => [session.id, session]));
|
||||
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
|
||||
const affectedIds = collectLocalAiSessions().filter((local) => {
|
||||
if (preserveActive(local.id)) return false;
|
||||
const next = incomingById.get(local.id);
|
||||
if (!next) return true;
|
||||
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
|
||||
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
|
||||
}).map((session) => session.id);
|
||||
if (affectedIds.length > 0) {
|
||||
const { stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
|
||||
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
|
||||
}
|
||||
// Stop finalizers can finish the last local message. Read it again before
|
||||
// merging so the stopped turn cannot write into a replaced/deleted session.
|
||||
const localSessions = collectLocalAiSessions();
|
||||
const localById = new Map(localSessions.map((session) => [session.id, session]));
|
||||
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id)).map((session) => session.id);
|
||||
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id) && !preserveActive(session.id)).map((session) => session.id);
|
||||
const resetSdkSessions: string[] = [];
|
||||
// The sync merge has already decided which session IDs survive. Backups
|
||||
// restore their exact transcript; cloud survivors still retain local turns.
|
||||
const candidates = includeLocalOnlyData ? incoming
|
||||
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
|
||||
const mergedSessions = candidates.map((session) => {
|
||||
// Keep active cloud sessions intact until a later sync, including remote deletions.
|
||||
const retainedActive = localSessions.filter((session) => preserveActive(session.id));
|
||||
const mergedSessions = [...candidates.filter((session) => !preserveActive(session.id)), ...retainedActive].map((session) => {
|
||||
if (preserveActive(session.id)) return session;
|
||||
// Native SDK threads belong to this device. Reuse the local thread
|
||||
// only while it already contains the entire merged transcript.
|
||||
const local = localById.get(session.id);
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -40,3 +40,35 @@ test('terminal side panel tools share one primary toolbar height', () => {
|
||||
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
}
|
||||
});
|
||||
|
||||
test('/rename and /chats panel commands stay out of the agent message path', () => {
|
||||
const sidePanel = preparingSource;
|
||||
// Panel implements the commands with updateSessionTitle + a note state —
|
||||
// never addMessageToSession, so the commands cannot leak into chat history.
|
||||
assert.match(sidePanel, /handleRenameSession = useCallback/);
|
||||
assert.match(sidePanel, /handleListChats = useCallback/);
|
||||
assert.match(sidePanel, /updateSessionTitle\(sessionId, title\)/);
|
||||
assert.match(sidePanel, /setPanelCommandNote\(formatAiChatsListingNote\(entries, t\)\)/);
|
||||
const renameHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleRenameSession = useCallback'),
|
||||
sidePanel.indexOf('const handleRenameSlashCommand'),
|
||||
);
|
||||
assert.doesNotMatch(renameHandler, /addMessageToSession/);
|
||||
const chatsHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleListChats = useCallback'),
|
||||
sidePanel.indexOf('const handleSend'),
|
||||
);
|
||||
assert.doesNotMatch(chatsHandler, /addMessageToSession/);
|
||||
// Wiring reaches AIChatPanelContent.
|
||||
assert.match(sidePanel, /onRenameActiveChat=\{handleRenameSlashCommand\}/);
|
||||
assert.match(sidePanel, /onListChats=\{handleListChats\}/);
|
||||
});
|
||||
|
||||
test('AIChatPanelContent renders the panel command note and wires rename/list props', () => {
|
||||
assert.match(panelSource, /panelCommandNote \? \(/);
|
||||
assert.match(panelSource, /role="status"/);
|
||||
assert.match(panelSource, /onDismissPanelCommandNote/);
|
||||
assert.match(panelSource, /onRenameSession=\{onRenameActiveChat\}/);
|
||||
assert.match(panelSource, /onListChats=\{onListChats\}/);
|
||||
assert.match(panelSource, /onRename=\{onRenameSession\}/);
|
||||
});
|
||||
@@ -57,6 +57,20 @@ interface AIChatPanelContentProps {
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
|
||||
onRenameSession?: (sessionId: string, title: string) => void;
|
||||
/** `/rename <title>` for the current chat. Panel action, never agent content. */
|
||||
onRenameActiveChat?: (title: string) => void;
|
||||
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
|
||||
onListChats?: () => void;
|
||||
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
|
||||
onRebindChat?: (arg: string) => void;
|
||||
/** `/chats` listing output, rendered as a system-style note above the composer. */
|
||||
panelCommandNote?: string | null;
|
||||
onDismissPanelCommandNote?: () => void;
|
||||
/** Manual durability escape hatch: export/import the whole session store. */
|
||||
onExportSessions?: () => void;
|
||||
onImportSessions?: () => void;
|
||||
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
||||
canUndoLastTurn?: boolean;
|
||||
handleUndoLastTurn?: () => void;
|
||||
@@ -142,6 +156,14 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
onRenameSession,
|
||||
onRenameActiveChat,
|
||||
onListChats,
|
||||
onRebindChat,
|
||||
panelCommandNote,
|
||||
onDismissPanelCommandNote,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
@@ -309,6 +331,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={sending ? () => undefined : handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onRename={onRenameSession}
|
||||
onExportSessions={onExportSessions}
|
||||
onImportSessions={onImportSessions}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
@@ -369,6 +394,21 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<div>
|
||||
{panelCommandNote ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<span className="block max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{panelCommandNote}</span>
|
||||
{onDismissPanelCommandNote && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onDismissPanelCommandNote}
|
||||
className="mt-1 text-[11px] text-muted-foreground/60 underline-offset-2 transition-colors hover:text-foreground hover:underline cursor-pointer"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{steerWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{steerWarning}
|
||||
@@ -396,6 +436,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onCompact={handleCompact}
|
||||
onRenameSession={onRenameActiveChat}
|
||||
onListChats={onListChats}
|
||||
onRebindChat={onRebindChat}
|
||||
canCompact={canCompact}
|
||||
onSteer={handleSteer}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Search, Trash2, X } from 'lucide-react';
|
||||
import { Download, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -19,6 +19,12 @@ interface SessionHistoryDrawerProps {
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
/** Rename a chat inline (issue #4015 R5). Empty titles are ignored. */
|
||||
onRename?: (sessionId: string, title: string) => void;
|
||||
/** Manual durability escape hatch: export the whole store to a JSON file. */
|
||||
onExportSessions?: () => void;
|
||||
/** Import sessions from a JSON export; merges by id, newer wins. */
|
||||
onImportSessions?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -30,11 +36,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { filteredSessions, isSearching } = useSessionHistorySearch(
|
||||
@@ -52,16 +64,69 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const displayedSessions = filteredSessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingSessionId) renameInputRef.current?.focus();
|
||||
}, [renamingSessionId]);
|
||||
|
||||
const beginRename = (sessionId: string, currentTitle: string) => {
|
||||
setRenamingSessionId(sessionId);
|
||||
setRenameDraft(currentTitle);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (renamingSessionId && renameDraft.trim()) {
|
||||
onRename?.(renamingSessionId, renameDraft.trim());
|
||||
}
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onImportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.importSessions')}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.importSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onExportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.exportSessions')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 shrink-0 border-b border-border/30">
|
||||
<div className="relative">
|
||||
@@ -146,12 +211,52 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
{renamingSessionId === session.id ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
aria-label={t('ai.chat.renameChat')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
className="h-6 text-[12px]"
|
||||
/>
|
||||
) : (
|
||||
session.title || t('ai.chat.untitled')
|
||||
)}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
{onRename && renamingSessionId !== session.id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(session.id, session.title || '');
|
||||
}}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.renameChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
type DefaultTargetSessionHint,
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
@@ -279,6 +280,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -312,7 +316,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
workspaces = [],
|
||||
terminalSessions = [],
|
||||
liveScopeTargetIds,
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
notes = [],
|
||||
@@ -327,6 +333,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
// Inline system-style note above the composer (e.g. `/chats` listing).
|
||||
// Panel-command output, never agent content.
|
||||
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
|
||||
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
|
||||
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Undo-last-turn is still
|
||||
// running. Unlike the send flow this is not reflected in `isSending`, so it
|
||||
@@ -484,6 +495,119 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
/** Rename a chat (slash command + history drawer affordance). Panel action: nothing is sent to the agent. */
|
||||
const handleRenameSession = useCallback((sessionId: string, rawTitle: string) => {
|
||||
const title = rawTitle.trim();
|
||||
if (!title) {
|
||||
toast.warning(t('ai.chat.renameUsage'));
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
toast.warning(t('ai.chat.renameNoActive'));
|
||||
return;
|
||||
}
|
||||
updateSessionTitle(sessionId, title);
|
||||
toast.success(t('ai.chat.renameDone').replace('{title}', title));
|
||||
}, [t, updateSessionTitle]);
|
||||
|
||||
const handleRenameSlashCommand = useCallback((title: string) => {
|
||||
handleRenameSession(activeSessionRef.current?.id ?? null, title);
|
||||
}, [handleRenameSession]);
|
||||
|
||||
const handleListChats = useCallback(() => {
|
||||
// Prefer the app-wide live target set so chats bound to other still-open
|
||||
// terminals are not mislabelled dangling / offered for /rebind.
|
||||
const liveTerminalIds = liveScopeTargetIds
|
||||
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? workspaceMemberTerminalIds
|
||||
: new Set(scopeTargetId ? [scopeTargetId] : []));
|
||||
// Dangling chats no longer match any live scope (rank 0), so they must be
|
||||
// appended explicitly for the listing — that is where they become visible
|
||||
// and rebindable again (issue #4015 R2).
|
||||
const listedSessionIds = new Set(historySessions.map((session) => session.id));
|
||||
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
|
||||
const entries = buildAiChatsListingEntries({
|
||||
sessions: [...historySessions, ...danglingSessions],
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces,
|
||||
liveTerminalIds,
|
||||
currentScopeLabel: scopeLabel,
|
||||
scopeTargetId,
|
||||
t,
|
||||
});
|
||||
lastChatsListingRef.current = entries;
|
||||
setPanelCommandNote(formatAiChatsListingNote(entries, t));
|
||||
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
|
||||
// to this panel's current scope. Panel action — no agent round-trip.
|
||||
const handleRebindChat = useCallback((arg: string) => {
|
||||
if (!scopeTargetId) {
|
||||
toast.warning(t('ai.chat.rebindUnavailable'));
|
||||
return;
|
||||
}
|
||||
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
|
||||
if (!target) {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
return;
|
||||
}
|
||||
const session = sessions.find((item) => item.id === target.sessionId);
|
||||
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
|
||||
for (const workspace of workspaces) liveTargets.add(workspace.id);
|
||||
liveTargets.add(scopeTargetId);
|
||||
if (canRebindChat(session, liveTargets)) {
|
||||
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
|
||||
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
|
||||
} else {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
}
|
||||
// Do not rebuild /chats from this render's stale sessions (still dangling).
|
||||
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
|
||||
// cannot target it; the next explicit /chats refreshes from live state.
|
||||
const remaining = lastChatsListingRef.current.filter(
|
||||
(entry) => entry.sessionId !== target.sessionId,
|
||||
);
|
||||
let danglingCounter = 0;
|
||||
for (const entry of remaining) {
|
||||
if (!entry.isDangling) {
|
||||
entry.rebindNumber = undefined;
|
||||
continue;
|
||||
}
|
||||
danglingCounter += 1;
|
||||
entry.rebindNumber = danglingCounter;
|
||||
}
|
||||
lastChatsListingRef.current = remaining;
|
||||
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
|
||||
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// Manual durability actions (history drawer header). Export writes the
|
||||
// whole store to a user-chosen JSON file; import merges by id.
|
||||
const handleExportSessions = useCallback(() => {
|
||||
void exportSessionsBackup?.().then((filePath) => {
|
||||
if (filePath == null) {
|
||||
toast.warning(t('ai.chat.exportUnavailable'));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
filePath
|
||||
? t('ai.chat.exportDone').replace('{path}', filePath)
|
||||
: t('ai.chat.exportDoneNoPath'),
|
||||
);
|
||||
});
|
||||
}, [exportSessionsBackup, t]);
|
||||
|
||||
const handleImportSessions = useCallback(() => {
|
||||
void importSessionsBackup?.().then((count) => {
|
||||
if (count <= 0) {
|
||||
toast.warning(t('ai.chat.importNone'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('ai.chat.importDone').replace('{n}', String(count)));
|
||||
});
|
||||
}, [importSessionsBackup, t]);
|
||||
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
|
||||
@@ -2065,6 +2189,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
onRenameSession={handleRenameSession}
|
||||
onRenameActiveChat={handleRenameSlashCommand}
|
||||
onListChats={handleListChats}
|
||||
onRebindChat={handleRebindChat}
|
||||
panelCommandNote={panelCommandNote}
|
||||
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
|
||||
onExportSessions={handleExportSessions}
|
||||
onImportSessions={handleImportSessions}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
@@ -2143,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'createSession',
|
||||
'deleteSession',
|
||||
'updateSessionTitle',
|
||||
'rebindSessionScope',
|
||||
'exportSessionsBackup',
|
||||
'importSessionsBackup',
|
||||
'updateSessionExternalSessionId',
|
||||
'addMessageToSession',
|
||||
'updateLastMessage',
|
||||
@@ -2193,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
|
||||
if (prev.workspaces !== next.workspaces) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface AIChatSidePanelProps {
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
|
||||
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
|
||||
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
|
||||
exportSessionsBackup?: () => Promise<string | null>;
|
||||
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
|
||||
importSessionsBackup?: () => Promise<number>;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
@@ -103,6 +109,10 @@ export interface AIChatSidePanelProps {
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
/** Live workspaces, used to resolve workspace titles for `/chats`. */
|
||||
workspaces?: Array<{ id: string; title: string }>;
|
||||
/** All currently live terminal (and workspace) scope target ids. */
|
||||
liveScopeTargetIds?: Set<string>;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
|
||||
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.sshInputCompatibility")}
|
||||
hint={t("hostDetails.sshInputCompatibility.desc")}
|
||||
enabled={form.sshInputCompatibility ?? true}
|
||||
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sshInputCompatibility.reconnect")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
os: "linux",
|
||||
authMethod: undefined,
|
||||
authPolicyVersion: 1,
|
||||
sshInputCompatibility: false,
|
||||
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { Host, Identity } from "../types";
|
||||
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
|
||||
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
localStorage: dom.window.localStorage,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
|
||||
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
|
||||
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
|
||||
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const identity: Identity = {
|
||||
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
|
||||
authMethod: "password", password: "fixture-old", created: 0,
|
||||
};
|
||||
const identities = scenario.identityUsername === undefined ? [] : [identity];
|
||||
const originalIdentity = { ...identity };
|
||||
const host: Host = {
|
||||
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
|
||||
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
|
||||
password: "fixture-host-old", savePassword: false,
|
||||
...(identities.length ? { identityId: identity.id } : {}),
|
||||
};
|
||||
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
|
||||
let hosts = [host, otherHost];
|
||||
let updates = 0;
|
||||
const request = {
|
||||
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
|
||||
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
|
||||
allowSavePassword: scenario.allowSavePassword ?? true,
|
||||
};
|
||||
let queue = [request];
|
||||
const responses: unknown[][] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en">
|
||||
<KeyboardInteractiveModal request={request} onCancel={() => false}
|
||||
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
|
||||
hosts, identities, keyboardInteractiveQueue: queue,
|
||||
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
|
||||
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
responses.push(args);
|
||||
return { success: true };
|
||||
} }) },
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
|
||||
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
|
||||
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
|
||||
}), requestId, values, savePassword)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
|
||||
assert.ok(input);
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
|
||||
else {
|
||||
assert.ok(checkbox);
|
||||
assert.equal(checkbox.checked, false);
|
||||
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
|
||||
assert.ok(submit);
|
||||
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
|
||||
assert.deepEqual(queue, []);
|
||||
const saved = scenario.remember && scenario.allowSavePassword !== false;
|
||||
assert.equal(updates, saved ? 1 : 0);
|
||||
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
|
||||
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
|
||||
assert.equal(hosts[0].savePassword, saved);
|
||||
assert.equal(hosts[1], otherHost);
|
||||
assert.deepEqual(identity, originalIdentity);
|
||||
|
||||
// Exercise the reconnect launcher that builds the actual SSH bridge request.
|
||||
for (const [index, candidate] of hosts.entries()) {
|
||||
let started: { username: string; password?: string } | undefined;
|
||||
const ctx = {
|
||||
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
|
||||
sessionId: `reconnect-${index}`, terminalSettings: {},
|
||||
sessionRef: { current: null }, hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null }, fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
|
||||
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
|
||||
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
|
||||
setProgressValue: noop, setChainProgress: noop,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
|
||||
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
|
||||
writeToSession: noop, resizeSession: noop,
|
||||
},
|
||||
};
|
||||
await createTerminalSessionStarters(ctx as never).startSSH({
|
||||
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
|
||||
} as never);
|
||||
assert.ok(started);
|
||||
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
|
||||
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
|
||||
}
|
||||
// A fresh request resets the checkbox and submitting state for the next case.
|
||||
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
|
||||
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('localStorage', dom.window.localStorage);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
|
||||
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
let panel: JSDOM | undefined;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let locked = false; let contentAllowed = false;
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const endpoint = `http://127.0.0.1:${address.port}/v1`;
|
||||
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
|
||||
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
|
||||
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
|
||||
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
|
||||
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
|
||||
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
|
||||
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: 1 }; } } },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string; relatedPermissions?: Array<{ permission: string }> }) => (request.permission === 'context.selected.read' || request.relatedPermissions?.some((item) => item.permission === 'context.selected.read')) && !contentAllowed
|
||||
? { requestId: request.requestId, decision: 'deny' }
|
||||
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
|
||||
});
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
const sample = path.join(rootPath, 'examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
|
||||
}
|
||||
};
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
assert.equal(noteChoice.options.length, 2);
|
||||
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent === 'Ready');
|
||||
assert.match(element('model').textContent!, /QA fixture/);
|
||||
const reference = element('context') as HTMLSelectElement;
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
|
||||
// the sample's pending callbacks before teardown closes its document.
|
||||
if (panel) { panel.window.disposePanel(); panel.window.close(); }
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
dom.window.close();
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
test('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
|
||||
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
|
||||
// real model/Claude acceptance. No real shell, remote host or credentials.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
|
||||
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); };
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('localStorage', dom.window.localStorage); install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client');
|
||||
const { PluginAgentHost } = await import('./plugins/PluginAgentHost');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const keys = await import('../infrastructure/config/storageKeys');
|
||||
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-provider-fixture.mjs');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
|
||||
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
|
||||
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
|
||||
const privacy = require('../electron/shared/aiPrivacyService.cjs');
|
||||
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
|
||||
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
|
||||
const errors: string[] = []; const originalError = console.error;
|
||||
let phase = 'setup';
|
||||
// Flush the known Node sqlite import warning before observing safe categories.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
|
||||
try {
|
||||
privacy.configure({ enabled: false }); server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string'); const endpoint = `http://127.0.0.1:${address.port}/v1`;
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
|
||||
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
|
||||
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
|
||||
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
|
||||
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
|
||||
} };
|
||||
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
|
||||
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
|
||||
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
|
||||
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
|
||||
// The production stream function owns this map. Turn release below uses
|
||||
// its actual main lease; direct SDK abort still calls the ordinary route.
|
||||
activeStreams: new Map() };
|
||||
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.match(request.requestId, /^agent-model_/);
|
||||
}
|
||||
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
|
||||
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
|
||||
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
|
||||
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
|
||||
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
|
||||
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
|
||||
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
|
||||
]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
|
||||
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
|
||||
} };
|
||||
let viewOpen = true;
|
||||
const mountPanel = async () => {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
|
||||
// PluginViewHost.close removes sender authority before WebContents
|
||||
// closes. A pagehide RPC cannot be relied on to reach this relay.
|
||||
if (!viewOpen) throw Error('Untrusted plugin view sender');
|
||||
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
|
||||
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
|
||||
};
|
||||
await mountPanel();
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
let prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
|
||||
assert.match(element('usage').textContent!, /input tokens/);
|
||||
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
|
||||
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
|
||||
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
|
||||
phase = 'modal-reopen-history';
|
||||
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
|
||||
const beforeReopen = counts.requests;
|
||||
viewOpen = false;
|
||||
panel!.window.disposePanel(); panel!.window.close();
|
||||
viewOpen = true; await mountPanel();
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
|
||||
await act(async () => element('conversation').dispatchEvent(new panel!.window.Event('change')));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click());
|
||||
await waitFor(() => /^(Ready|Conversation unavailable)/.test(element('status').textContent!));
|
||||
assert.match(element('status').textContent!, /^Ready/, 'ordinary modal reopen must release the prior exact chat even if pagehide cleanup never reaches the plugin');
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(service.agentService.chats.size, 1); assert.equal(counts.requests, beforeReopen, 'history reopen does not invoke a model');
|
||||
prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic continuation';
|
||||
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
|
||||
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
|
||||
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
phase = 'restart-history';
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', false);
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', true);
|
||||
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
|
||||
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
|
||||
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
phase = 'backend-revision';
|
||||
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
|
||||
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
|
||||
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
|
||||
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
|
||||
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
|
||||
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
|
||||
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
|
||||
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
if (panel) { panel.window.disposePanel(); panel.window.close(); }
|
||||
await act(async () => root.unmount()); await service?.manager.shutdown();
|
||||
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -43,11 +43,11 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
let locked = false;
|
||||
let onLock: ((state: { locked: boolean }) => void) | undefined;
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
|
||||
getInteractionWindow: () => owner, isAppLocked: () => locked,
|
||||
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
|
||||
subscribeAppLock: (callback: (state: { locked: boolean }) => void) => { lockListeners.add(callback); return () => lockListeners.delete(callback); },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
|
||||
});
|
||||
let requestClock = Date.now();
|
||||
@@ -109,6 +109,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
const archive = path.join(directory, 'fixture.ncpkg');
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
|
||||
service.interactionService.setReady(sender, true);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
|
||||
|
||||
@@ -180,7 +181,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
await t.test('Vault lock cancels a pending connection', async () => {
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => button('Connect') !== undefined);
|
||||
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
|
||||
await act(async () => { locked = true; publish(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
|
||||
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
|
||||
locked = false; publish();
|
||||
});
|
||||
@@ -192,8 +193,8 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
|
||||
await waitFor(() => preparing);
|
||||
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
|
||||
await act(async () => { service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'deny'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined; service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'allow');
|
||||
});
|
||||
|
||||
await t.test('source replacement during review cannot use the old action authority', async () => {
|
||||
@@ -272,13 +273,44 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('Allow connects through the selected target without a confirmation dialog; Deny blocks every Host action', async () => {
|
||||
const id = 'com.netcatty.host-actions';
|
||||
const plugin = service.database.getActivePlugin(id);
|
||||
const set = (permission: string, mode: string) => service.permissionEngine.setMode(plugin, permission, mode);
|
||||
set('ui.interact', 'allow');
|
||||
const allowed = await begin('connect'); await chooseGroup();
|
||||
await act(async () => { await allowed.promise; await settle(); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(connections.length, 2);
|
||||
for (const [permission, command] of [['vault.metadata', 'select'], ['vault.write', 'create']] as const) {
|
||||
set(permission, 'deny'); requestClock += 60_001;
|
||||
await assert.rejects(service.contributionService.executeCommand(`${id}.${command}`), /disabled/);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
set(permission, 'allow');
|
||||
}
|
||||
set('ui.interact', 'deny');
|
||||
const denied = await begin('connect'); await chooseGroup();
|
||||
await assert.rejects(denied.promise, /disabled/);
|
||||
assert.equal(connections.length, 2); assert.equal(saves, 1);
|
||||
set('ui.interact', 'ask'); set('vault.write', 'ask');
|
||||
const draft = await begin('create');
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.ok(button('Save Host')); await click(button('Cancel'));
|
||||
assert.deepEqual(await draft.promise, { cancelled: true });
|
||||
set('vault.write', 'allow'); set('vault.metadata', 'ask');
|
||||
const selection = await begin('select'); await chooseGroup();
|
||||
assert.ok('hosts' in await selection.promise);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
set('vault.metadata', 'allow');
|
||||
});
|
||||
|
||||
await t.test('runtime removal rejects old signed replies', async () => {
|
||||
const run = await begin('create');
|
||||
const old = prompts.at(-1)!;
|
||||
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
|
||||
await assert.rejects(run.promise);
|
||||
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 2);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
|
||||
test('host dialog drives choice/input/copy and a single scoped clipboard share using real service action tokens', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
@@ -22,25 +22,34 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
|
||||
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
|
||||
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('./terminalPaneVisibility');
|
||||
const { createPluginTerminalSelectionSource } = await import('../application/state/usePluginTerminalSelection');
|
||||
const { registerTerminalSelectionSource } = await import('../infrastructure/plugins/terminalSelectionRegistry');
|
||||
const { privateOwner, LOCAL_SOURCE } = await import('../electron/plugins/sourceIdentity.cjs');
|
||||
let selectionReads = 0;
|
||||
let selectionText = 'synthetic terminal selection\nsecond line';
|
||||
let listener: ((event: unknown) => void) | null = null;
|
||||
let reads = 0;
|
||||
const writes: string[] = [];
|
||||
const audits: unknown[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
const terminalManager = { getPluginSessionBinding: (id: string, owner: number) => id === 'terminal-a' && owner === 7 ? { epoch: 'worker-a', generation: 1 } : null };
|
||||
const service = new PluginHostInteractionService({
|
||||
getWindow: () => owner, isLocked: () => false,
|
||||
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
|
||||
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
|
||||
getTerminalWorkerManager: () => terminalManager,
|
||||
database: { privateOwner: (id: string) => privateOwner(LOCAL_SOURCE, id), publicPluginId: () => 'com.example.synthetic', recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
|
||||
quotaManager: { chargeBytes() {} },
|
||||
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
|
||||
});
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async setPluginTerminalTarget(target: unknown) { return service.setTerminalTarget(sender, target); },
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
|
||||
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
|
||||
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
|
||||
} });
|
||||
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
|
||||
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', manifest: { features: { required: ['terminal.selection-sharing', 'terminal.input-drafts'] } }, signal: new AbortController().signal, async assertActive() {} };
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
@@ -71,14 +80,60 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
|
||||
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
await click(button('Preview clipboard'));
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
|
||||
await click(button('Share with plugin'));
|
||||
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
|
||||
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
|
||||
const terminalElement = document.createElement('div'); document.body.append(terminalElement);
|
||||
const terminal = { cols: 80, element: terminalElement,
|
||||
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }),
|
||||
getSelection() { selectionReads++; return selectionText; },
|
||||
} as unknown as import('@xterm/xterm').Terminal;
|
||||
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(getTerminalPaneRenderSnapshot({
|
||||
activeTabId: 'terminal-a', sessionId: 'terminal-a', workspaceById: new Map(), isTerminalLayerVisible: true,
|
||||
})));
|
||||
assert.equal(inputProps.isFocused, false);
|
||||
const selectionState = { term: terminal, sessionId: 'terminal-a', bootEpoch: 1, visible: inputProps.isVisible, isFocusedPane: inputProps.isFocusedPane, connected: true, sensitive: false, normalize: false, label: 'Synthetic selected terminal' };
|
||||
const cleanupSelection = registerTerminalSelectionSource('terminal-a', createPluginTerminalSelectionSource(() => selectionState, 'renderer-a'));
|
||||
try {
|
||||
const noShare = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /passwords, tokens/);
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Synthetic selected terminal/);
|
||||
await click(button('Cancel'));
|
||||
assert.deepEqual(await noShare.promise, { cancelled: true });
|
||||
assert.equal(selectionReads, 1);
|
||||
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
|
||||
assert.equal(selectionReads, 2);
|
||||
assert.equal(document.querySelector('textarea')?.value, selectionText);
|
||||
selectionText = 'changed after preview';
|
||||
await click(button('Share with plugin'));
|
||||
const result = await selected.promise as { cancelled: false; value: { text: string; handle: string } };
|
||||
assert.equal(result.value.text, 'synthetic terminal selection\nsecond line');
|
||||
assert.equal(selectionReads, 2);
|
||||
const draft = await begin({ kind: 'terminal.input.propose', handle: result.value.handle, title: 'Synthetic editable review', text: 'first\nsecond\r\u0003\u001b[31m\u0000' });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /sends no terminal input or Enter/);
|
||||
const textarea = document.querySelector('textarea')!;
|
||||
assert.equal(textarea.readOnly, false);
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, 'edited synthetic draft\nnext line');
|
||||
textarea.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
textarea.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await settle();
|
||||
});
|
||||
assert.equal(textarea.value, 'edited synthetic draft\nnext line');
|
||||
assert.equal(service.pending.size, 1, 'Enter in the editable review does not submit or dispatch');
|
||||
assert.match(document.querySelector('pre')?.textContent ?? '', /edited synthetic draft/);
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
await click(button('Close draft'));
|
||||
assert.deepEqual(await draft.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
selectionState.visible = false;
|
||||
const hidden = await begin({ kind: 'terminal.selection.read', title: 'Hidden terminal' });
|
||||
assert.deepEqual(await hidden.promise, { cancelled: true });
|
||||
assert.equal(selectionReads, 2);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
} finally { cleanupSelection(); terminalElement.remove(); }
|
||||
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
|
||||
await click(button('Cancel'));
|
||||
assert.deepEqual(await cancelled.promise, { cancelled: true });
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Test-only observer preserves the security-surface visibility assertions.
|
||||
function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
|
||||
const document = mount.ownerDocument;
|
||||
let visible = true;
|
||||
const update = () => {
|
||||
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
|
||||
const style = document.defaultView!.getComputedStyle(surface);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
|
||||
});
|
||||
if (visible === !blocked) return;
|
||||
visible = !blocked;
|
||||
publish(visible);
|
||||
};
|
||||
const observer = new document.defaultView!.MutationObserver(update);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
|
||||
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
|
||||
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
|
||||
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
|
||||
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: unknown) => { for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isFocused: () => true, isVisible: () => true });
|
||||
const host = new PluginPermissionDecisionHost({ getWindow: () => owner, isLocked: () => false });
|
||||
let mode = 'allow', effects = 0;
|
||||
const plugin = { id: 'com.example.permission-ui', activeVersion: '1.0.0', manifest: { id: 'com.example.permission-ui', publisher: 'example', permissions: { optional: [{ permission: 'network', resources: ['https://example.com'] }] } } };
|
||||
const engine = new PluginPermissionEngine({ database: { getPermissionMode: () => mode, privateOwner: () => 'synthetic-source-owner',
|
||||
setPermissionMode: (_id: string, _permission: string, value: string) => { mode = value; }, recordSecurityAudit() {}, deleteAllPermissionGrants() {}, listPermissionGrants: () => [] },
|
||||
requestDecision: (...args: unknown[]) => host.request(...args) });
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware());
|
||||
registry.registerRequest('network.fixture', async () => { effects++; return 'synthetic result'; }, { authorization: { permission: 'network', resources: ['https://example.com'] } });
|
||||
const route = registry.createRoutes({ pluginId: plugin.id, pluginVersion: '1.0.0', runtimeId: 'runtime-ui', runtimeKind: 'browser', manifest: plugin.manifest }).requestHandlers['network.fixture'];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginPermission(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
|
||||
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
|
||||
getPluginPermissions: async () => ({ grants: [], audit: [], policies: engine.describePolicies(plugin), policyRevision: engine.getPolicyRevision(plugin) }),
|
||||
setPluginPermissionMode: async (_id: string, permission: string, value: string, revision: string) => {
|
||||
assert.equal(revision, engine.getPolicyRevision(plugin)); engine.setMode(plugin, permission, value); return true;
|
||||
},
|
||||
} });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const nativeMount = document.createElement('div'); document.body.append(nativeMount);
|
||||
const nativeVisibility: boolean[] = [];
|
||||
const stopVisibility = observePluginHostSecurityVisibility(nativeMount, (visible) => nativeVisibility.push(visible));
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
|
||||
const choose = async (text: string) => {
|
||||
await act(async () => { document.querySelector('[role="combobox"]')!.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await settle(); });
|
||||
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) => element.textContent === text)!;
|
||||
assert.ok(option);
|
||||
await act(async () => { option.click(); await settle(); });
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId={plugin.id} onChanged={async () => {}} /><PluginPermissionHost /></I18nProvider>));
|
||||
await route({}, {}); assert.equal(effects, 1); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await choose('Ask'); assert.equal(mode, 'ask');
|
||||
let operation!: Promise<string>;
|
||||
await act(async () => { operation = route({}, {}); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
|
||||
assert.equal(nativeVisibility.at(-1), false, 'the existing native-view lifecycle must yield to the trusted Ask');
|
||||
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
|
||||
assert.equal(effects, 1);
|
||||
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
|
||||
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), false, 'visible permission settings remain a security surface after Ask closes');
|
||||
await choose('Deny'); assert.equal(mode, 'deny');
|
||||
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await choose('Allow'); assert.equal(mode, 'allow');
|
||||
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
// Model the settings editor closing before interacting with a native view.
|
||||
document.querySelector('section[data-host-security-surface]')!.setAttribute('hidden', '');
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true);
|
||||
let displayed!: Promise<unknown>;
|
||||
const opaque = 'selected:0cfc16d6-3448-4333-82ef-dd4ffbddfdab:v1';
|
||||
await act(async () => {
|
||||
displayed = host.request({ requestId: 'host-display-fixture', pluginId: plugin.id, permission: 'ai.invoke', resources: ['*'], reason: 'Host model invocation',
|
||||
relatedPermissions: [{ permission: 'context.selected.read', resources: [opaque] }],
|
||||
displayContext: { modelLabel: 'QA fixture · deterministic-fixture', selections: [{ title: 'Synthetic retry plan', scope: 'note', version: 'bf304994-039a-4884-a707-3a1a40a5d3a9' }] },
|
||||
}); await settle();
|
||||
});
|
||||
const text = document.querySelector('[role="dialog"]')!.textContent!;
|
||||
assert.equal(nativeVisibility.at(-1), false, 'Ask alone must hide the attached plugin view');
|
||||
assert.match(text, /QA fixture · deterministic-fixture/); assert.match(text, /Synthetic retry plan/);
|
||||
assert.doesNotMatch(text, /selected:|0cfc16d6|bf304994/);
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
|
||||
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
|
||||
await act(async () => {
|
||||
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
|
||||
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
|
||||
}); await settle();
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /Operation:.*get_environment/);
|
||||
assert.match(dialog.textContent!, /session\.environment/);
|
||||
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
|
||||
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
|
||||
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
|
||||
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('registered selected-summary Ask is one host chooser; Allow still selects and Deny releases nothing', { timeout: 45_000 }, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
|
||||
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
|
||||
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { registerPluginBridge } = require('../electron/plugins/pluginBridge.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-policy-'));
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel: string, handler: (...args: unknown[]) => unknown) => handlers.set(channel, handler), removeHandler: (channel: string) => handlers.delete(channel) });
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), { invoke: async (channel: string, payload: unknown) => {
|
||||
const handler = handlers.get(channel); assert.ok(handler, channel); return handler({ sender }, structuredClone(payload));
|
||||
} });
|
||||
const prompts: Array<{ channel: string; kind?: string; closed?: boolean }> = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 42, isDestroyed: () => false, send: (channel: string, payload: { kind?: string; closed?: boolean }) => {
|
||||
prompts.push({ channel, kind: payload.kind, closed: payload.closed }); ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
const service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => fixtureRoot, getVersion: () => '0.0.0' },
|
||||
electron: { ipcMain }, env: {}, getInteractionWindow: () => owner, isAppLocked: () => false });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
registerPluginBridge(ipcMain, { ...service, isTrustedSender: (event: { sender: unknown }) => event.sender === sender });
|
||||
Object.assign(dom.window, { netcatty: createPreloadApi({ ipcRenderer, webUtils: {} }) });
|
||||
const note = { id: 'synthetic-selected-note', title: 'Synthetic selection', content: 'BODY MUST NOT BE SHARED', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [note], noteGroups: [] }); registerAppAppLockRuntime({ locked: false } as never);
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitForDialog = async () => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!document.querySelector('[role="dialog"]')) {
|
||||
assert.ok(Date.now() < deadline, 'registered route did not present a host interaction');
|
||||
await act(async () => { await settle(); });
|
||||
}
|
||||
};
|
||||
const button = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === label)!;
|
||||
const begin = () => {
|
||||
const result = service.contributionService.executeCommand('com.netcatty.assistant.contexts') as Promise<Array<{ handle: string; version: string; title: string }>>;
|
||||
void result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
|
||||
const sample = path.resolve('examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
|
||||
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
const plugin = service.database.getActivePlugin('com.netcatty.assistant');
|
||||
for (const mode of ['ask', 'allow']) {
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', mode);
|
||||
let result!: ReturnType<typeof begin>;
|
||||
await act(async () => { result = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
assert.ok(document.getElementById('plugin-ai-note'), 'the first interaction must be the real host chooser');
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(prompts.filter((prompt) => prompt.channel === 'netcatty:plugins:permission-prompt' && !prompt.closed).length, 0, 'no capability dialog before the chooser');
|
||||
await act(async () => {
|
||||
const select = document.getElementById('plugin-ai-note') as HTMLSelectElement; select.value = note.id; select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); await settle();
|
||||
});
|
||||
await act(async () => { button('Share selected summaries').click(); await settle(); });
|
||||
const summaries = await result;
|
||||
assert.ok(summaries.some((item) => item.title === note.title));
|
||||
assert.doesNotMatch(JSON.stringify(summaries), /BODY MUST NOT BE SHARED/);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
}
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny');
|
||||
const count = prompts.filter((prompt) => prompt.kind === 'context.select').length;
|
||||
await assert.rejects(service.contributionService.executeCommand('com.netcatty.assistant.contexts'), /disabled/);
|
||||
assert.equal(prompts.filter((prompt) => prompt.kind === 'context.select').length, count); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', 'ask');
|
||||
let cancelled!: ReturnType<typeof begin>;
|
||||
await act(async () => { cancelled = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
await act(async () => { button('Cancel').click(); await settle(); });
|
||||
await assert.rejects(cancelled); assert.equal(service.aiContextService.selected.handles.size, 0);
|
||||
let revoked!: ReturnType<typeof begin>;
|
||||
await act(async () => { revoked = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
await act(async () => { service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny'); await settle(); });
|
||||
await assert.rejects(revoked); assert.equal(document.querySelector('[role="dialog"]'), null); assert.equal(service.aiContextService.selected.handles.size, 0);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); await service.manager.shutdown();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock); dom.window.close(); fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
install('PointerEvent', dom.window.MouseEvent);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
let rectX = 200;
|
||||
const rectX = 200;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
|
||||
const { default: React, act, useCallback, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('./ui/tooltip');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
|
||||
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
|
||||
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
|
||||
let selectedSession = 'synthetic-session-a';
|
||||
const getSession = () => selectedSession;
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
|
||||
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
|
||||
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
|
||||
const visible = new Map<string, boolean>();
|
||||
const changed = new Set<() => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
|
||||
onPluginViewClosed() { return () => {}; },
|
||||
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); },
|
||||
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
|
||||
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
|
||||
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
|
||||
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView() {},
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
|
||||
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
|
||||
if (!page || !target) return null;
|
||||
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
|
||||
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}</>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
try {
|
||||
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
|
||||
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
|
||||
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
|
||||
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
|
||||
await act(async () => { firstButton.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
|
||||
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
|
||||
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
await act(async () => { split(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
|
||||
assert.equal(opens.length, 2);
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
|
||||
focus.length = 0;
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
|
||||
const old = opens.map((item) => item.instanceId!);
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
|
||||
assert.equal(opens.length, 4);
|
||||
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
|
||||
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
|
||||
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
|
||||
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
|
||||
const last = opens.at(-1)!.instanceId!;
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
|
||||
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
|
||||
await act(async () => { localPage.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
|
||||
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
|
||||
let changed: (() => void) | undefined;
|
||||
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
|
||||
let defer: ((value: { instanceId: string }) => void) | undefined;
|
||||
let deferOpen = false;
|
||||
let delayLayout = false;
|
||||
const rejectOldLayout: Array<(error: Error) => void> = [];
|
||||
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
|
||||
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
|
||||
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
|
||||
onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView(id: string) { closes.push(id); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
|
||||
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
|
||||
assert.equal(opens.length, 1);
|
||||
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
|
||||
assert.equal(visibility.at(-1), true);
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.equal(visibility.at(-1), true);
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
|
||||
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(opens.length, 2);
|
||||
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
assert.ok(closes.includes(opens[1].instanceId!));
|
||||
// The main process owns immediate invalidation on disable/uninstall.
|
||||
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
|
||||
assert.ok(closes.includes(opens[0].instanceId!));
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
assert.equal(document.activeElement?.textContent, 'Hosts');
|
||||
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
|
||||
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
|
||||
deferOpen = true;
|
||||
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
|
||||
assert.equal(opens.length, 3);
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
|
||||
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
|
||||
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
|
||||
deferOpen = false;
|
||||
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
|
||||
delayLayout = true;
|
||||
await clickPage();
|
||||
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
delayLayout = false;
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
|
||||
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from './test-support/renderReactDom';
|
||||
|
||||
test('a rejected palette plugin command shows bounded existing toast feedback without raw diagnostics or retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = { localStorage: dom.window.localStorage, Element: dom.window.Element, ResizeObserver: class { observe() {} disconnect() {} } };
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { QuickSwitcher } = await import('./QuickSwitcher');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [], calls: unknown[][] = [];
|
||||
const error = toast.error;
|
||||
toast.error = (message) => { notices.push(message); };
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: {
|
||||
getPluginRuntimeStatus: async () => ({ available: true }),
|
||||
getPluginContributions: async () => ({ locale: 'en', plugins: [{
|
||||
id: 'com.example.selection', version: '1.0.0', displayName: 'Synthetic selection', description: '',
|
||||
commands: [{ id: 'com.example.selection.share', title: 'Share selected terminal', enabled: true }],
|
||||
menus: [{ id: 'menu', command: 'com.example.selection.share', title: 'Share selected terminal', location: 'commandPalette', visible: true, enabled: true }],
|
||||
keybindings: [], settings: [], views: [],
|
||||
}] }),
|
||||
executePluginCommand: async (...args: unknown[]) => { calls.push(args); throw new Error('synthetic-private-diagnostic'); },
|
||||
} });
|
||||
let closes = 0;
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><QuickSwitcher isOpen query="Share selected terminal" results={[]} sessions={[]} workspaces={[]}
|
||||
onQueryChange={() => {}} onSelect={() => {}} onSelectTab={() => {}} onClose={() => { closes++; }} showSftpTab={false} /></I18nProvider>);
|
||||
await flushEffects(); await flushEffects();
|
||||
const button = [...dom.document.querySelectorAll('button')].find((node) => node.textContent?.includes('Share selected terminal'));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(closes, 1);
|
||||
assert.deepEqual(notices, ['The plugin command could not be completed. Check its required selection and permissions.']);
|
||||
assert.equal(notices.join('').includes('synthetic-private-diagnostic'), false);
|
||||
} finally {
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMono
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { requestOpenPluginView } from "./plugins/PluginContributionHost";
|
||||
import { PluginContributionIcon } from "./plugins/PluginContributionIcon";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
type QuickSwitcherItemBase = {
|
||||
id: string;
|
||||
@@ -507,7 +508,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
useAlternate && item.altCommand ? item.altCommand : item.commandId,
|
||||
undefined,
|
||||
{ 'netcatty.surface': 'commandPalette' },
|
||||
).catch(() => {});
|
||||
).catch(() => toast.error(t('plugins.command.failed')));
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
|
||||
Loaded 100 of 648 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user