Compare commits

...
Author SHA1 Message Date
陈奇 56fefdd03f fix(plugin-agent): separate durable conversations from revoked runtime actors 2026-10-27 09:03:40 +00:00
陈奇 b7e4abb30b merge: carry isolated delegated transport review corrections into runtime
# Conflicts:
#	electron/bridges/mcpServerBridge.cjs
#	electron/bridges/mcpServerBridge.pluginDelegation.test.cjs
2026-10-27 08:50:20 +00:00
陈奇 f715a0764d feat(plugin-agent): wire core global conversations and minimal Chat sample 2026-10-27 08:49:54 +00:00
陈奇 63f0572e0b fix(plugin-agent): guard delegated cancellation and empty context paths 2026-10-27 08:45:57 +00:00
陈奇 ea619555ac feat(plugin-agent): bind delegated MCP and model transport to exact turns 2026-10-27 07:43:52 +00:00
陈奇 ba4d3fc223 fix(plugin-agent): keep RPC cancellation separate from scope revocation 2026-10-27 06:56:03 +00:00
陈奇 18377a1fd9 fix(plugin-agent): revoke failed owner scopes and bind Stop to its turn 2026-10-27 06:41:17 +00:00
陈奇 056164157f feat(plugin-agent): define source-bound global session authority and contract 2026-10-27 06:09:48 +00:00
陈奇 2433cf0130 docs(plugins): prepare isolated Ask routing QA and preserve 020 results 2026-10-27 05:46:36 +00:00
陈奇 1b21c41fa2 Merge reviewed managed-window Ask routing into isolated acceptance candidate 2026-10-27 05:31:36 +00:00
陈奇 89beeb9374 fix(plugins): route Ask to focused managed app windows 2026-10-27 05:06:38 +00:00
陈奇 02049102b6 docs(plugins): refresh final policy candidate provenance and native limits 2026-10-27 04:07:45 +00:00
陈奇 cfa13680e6 Merge authoritative policy disclosure correction preserving all assertions 2026-10-27 04:01:44 +00:00
陈奇 79a9bd6df1 fix(plugins): describe policy modes without mandatory consent copy 2026-10-27 03:51:51 +00:00
陈奇 7856c664c3 docs(plugins): record isolated policy and AI candidate with exact sample provenance 2026-10-27 03:43:01 +00:00
陈奇 7d8a354404 test(plugins): align package disclosure assertions with declared capability policy 2026-10-27 03:34:50 +00:00
陈奇 da4e80946d test(plugins): align package details with default Allow policy 2026-10-27 03:34:32 +00:00
陈奇 5d2ff1cb86 Merge reviewed AI startup and themed sample while preserving permission policy assertions 2026-10-27 03:25:05 +00:00
陈奇 9aa899059f Merge declared capability policy corrections into isolated acceptance candidate 2026-10-27 03:24:18 +00:00
陈奇 2eee561847 fix(plugins): bind scoped Ask receipts and revoke advanced activations 2026-10-27 03:17:53 +00:00
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +00:00
陈奇 643e71b17b feat(plugins): default declared capabilities to allow with scoped policy controls 2026-10-27 02:28:08 +00:00
陈奇 e325742abf fix(example-assistant): follow host theme and accent environment 2026-10-27 02:22:20 +00:00
陈奇 9ca70f3290 docs(plugin-ai): record cold-start evidence and native verification limits 2026-10-27 02:03:21 +00:00
陈奇 092438803b fix(plugin-ai): prepare host provider transport independently of the AI sidebar 2026-10-27 02:00:37 +00:00
陈奇 677aac33b1 docs(plugins): hand off verified correction candidate and rebuilt sample hashes 2026-10-26 20:15:22 +00:00
陈奇 24254ed737 Merge verified assistant selected-Note recovery into acceptance 2026-10-26 20:12:05 +00:00
陈奇 bbcf9c01c3 Merge verified terminal selection and inert draft correction into acceptance 2026-10-26 20:08:08 +00:00
陈奇 ba09461676 test(plugin-ai): verify repeated selected Note expiry recovery and update QA 2026-10-26 19:55:58 +00:00
陈奇 54b6cd401f docs(plugins): record completed AI review and active native holds 2026-10-26 19:55:55 +00:00
陈奇 9e7c9df58e fix(example-assistant): explicitly recover expired selected Note references 2026-10-26 19:53:18 +00:00
陈奇 824c3838e9 Merge published AI source-budget and model-session fixes provisionally 2026-10-26 19:50:06 +00:00
陈奇 35b65ef1d7 Merge verified settings gear focus correction into acceptance 2026-10-26 19:46:41 +00:00
陈奇 8e7c28fe1b fix(plugins): use effective pane ownership for terminal sharing 2026-10-26 19:35:41 +00:00
陈奇 07eb68c5e7 Merge current source and stream base into plugin AI corrections 2026-10-26 19:34:20 +00:00
陈奇 d81896b0d3 docs(plugin-ai): record bounded review repairs and replacement sample hash 2026-10-26 19:25:57 +00:00
陈奇 e1cb95ee87 fix(example-assistant): recover expired model sessions without inference retries 2026-10-26 19:23:22 +00:00
陈奇 ee5a4a4067 fix(plugin-ai): preserve source budgets across runtime replacement 2026-10-26 19:23:22 +00:00
陈奇 3e5d450a1e fix(plugins): publish selection targets after runtime mount and wake 2026-10-26 19:00:02 +00:00
陈奇 823db94cf3 fix(plugins): focus settings after direct gear navigation 2026-10-26 18:56:13 +00:00
陈奇 174c525cd4 test(plugins): validate unified acceptance entrypoints and record gates 2026-10-26 18:51:33 +00:00
陈奇 3cfc7db371 feat(plugins): complete selected terminal sharing and inert input drafts 2026-10-26 18:42:37 +00:00
陈奇 6cc7ab9501 merge: retain selected-terminal sharing WIP history 2026-10-26 18:42:37 +00:00
陈奇 4cbb34517a merge: reconcile current main with plugin integration 2026-10-26 18:42:37 +00:00
陈奇 2278681787 Merge verified settings and logging UI into acceptance candidate 2026-10-26 18:34:59 +00:00
陈奇 31d61e3040 Merge verified host and Vault actions into acceptance candidate 2026-10-26 18:34:25 +00:00
陈奇 52d4af2a86 Merge verified plugin byte stream integration onto acceptance main 2026-10-26 18:34:02 +00:00
陈奇 dc6e645fab fix(plugins): prebundle permission panel helper in Vite development 2026-10-26 18:18:52 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

* test(ssh): verify packaged cipher transport across platforms

* test(ssh): isolate unused Electron UI APIs in packaged transport harness

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* fix: clear keychain ref when remembering KI password change

Keyboard-interactive saves wrote the new host password but left
identityId attached. resolveHostAuth prefers the shared identity
password, so the next login kept the stale secret.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

resolveHostAuth prefers identity.username. Detaching the identity
without copying that username left the host's stale account next to
the new password.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: fill blank username only when detaching KI keychain identity

detachEffectiveHostIdentity copies the effective username only when the
host username is blank. Always replacing a set username went past that
helper.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

Clearing identityId left a different host username next to the new
password. Copy the identity username onto the host whenever it is
non-empty, matching the vault password detach.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): exercise KI remember input and reconnect

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈奇 41313293e1 fix(i18n): preserve Spanish parity for plugin settings 2026-10-26 17:11:19 +00:00
binaricat 730fa643ba fix(plugins): align host confirmation and refuse managed drafts 2026-10-26 17:07:41 +00:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈奇 d16e21c848 docs(plugins): describe compact settings and manual update checks 2026-10-26 17:06:45 +00:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 e6dd68b209 feat(plugins): check published updates from bound source catalogs 2026-10-26 17:00:56 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +00:00
陈奇 eb6772355e fix(plugins): preserve settings navigation and accessible dialog controls 2026-10-26 16:42:16 +00:00
binaricat 267bcbfb8b fix(plugins): reconcile shared permission descriptions 2026-10-26 16:37:40 +00:00
陈奇 17c48b4d79 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:36:39 +00:00
陈奇 8eefd9de96 feat(plugins): use compact settings rows and installed quick controls 2026-10-26 16:29:18 +00:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
binaricat 739cab2965 fix(plugins): lock inherited assistant example workspace 2026-10-26 16:18:09 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
binaricat cecfa3a47e fix(plugins): complete host action dialog locale parity 2026-10-26 16:11:33 +00:00
binaricat 0a590b4ebc feat(plugins): wire confirmed host and Vault actions 2026-10-26 16:07:10 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
binaricat 69c2cc8d1f Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into codex/plugin-host-vault 2026-10-26 15:41:43 +00:00
binaricat ccfe41f39a feat(plugins): prepare host action service and renderer execution 2026-10-26 15:41:42 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
binaricat d5cb8cfd3b feat(plugins): prepare safe host draft review boundaries 2026-10-26 15:24:10 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈大猫 58c6a216ac feat: save SSH input compatibility per host (#3977)
* feat: save SSH input compatibility per host

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

* fix: default new SSH link and importer drafts to standard input

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

* docs: record final native input compatibility acceptance
2026-10-26 23:16:06 +08:00
陈奇 2a9c310498 Merge frozen plugin sources and logging for native QA
Source parent: 4213d8e672
Logging parent: 9b853130ca

Ordinary merge with no manual conflicts or additional source edits.
2026-10-26 15:14:08 +00:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
陈大猫 c4433d4961 feat(plugins): integrate source-bound byte streams and additive AI contract 2026-10-26 23:04:54 +08:00
陈奇 9b853130ca fix(plugins): bind log provenance and enforce segment retention 2026-10-26 15:00:54 +00:00
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
陈奇 264f1d983c feat(plugin-ai): define registered service and assistant runtime adapters 2026-10-26 14:47:58 +00:00
陈奇 35e3f9f99f Merge branch 'feat/plugin-ai-context-core' into feat/plugin-ai-context 2026-10-26 14:47:58 +00:00
陈奇 4882e0501c fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:37 +00:00
陈奇 c3a5f810ce fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:30 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
陈大猫 dd41f9d8d0 feat(plugins): prepare bounded byte transport and database fixture 2026-10-26 22:28:28 +08:00
陈奇 e3cc23bca8 feat(plugins): add opt-in local diagnostics and host audit logs 2026-10-26 14:26:41 +00:00
陈奇 6392ac9532 feat(plugin-ai): reuse host model transport and add assistant panel fixture 2026-10-26 14:25:34 +00:00
陈奇 afe831bfc5 feat(plugin-ai): add source-bound model and selected context brokers 2026-10-26 14:25:18 +00:00
Codex ab8764a317 Merge reviewed theme security color aliases into source integration 2026-10-26 14:06:02 +00:00
Codex 7e72ae65ad test(plugins): assert catalog operations bind official source 2026-10-26 14:02:00 +00:00
Codex f2e878858f test(plugins): drain dialog autofocus cleanup before restoring DOM globals 2026-10-26 13:49:24 +00:00
Codex 2ff86c2217 fix(plugins): restore opener focus after source and install dialogs 2026-10-26 13:42:31 +00:00
Codex 5e6278cbc7 fix(plugins): distinguish installed packages from mirrored source entries 2026-10-26 13:34:59 +00:00
Codex 83952aac0d fix(plugins): resolve source-scoped credential owners for provider recovery 2026-10-26 13:31:36 +00:00
Codex 7ec6473112 fix(plugins): retain local provenance through source schema upgrades 2026-10-26 13:28:34 +00:00
Codex 17e6834f74 test(plugins): include source binding in catalog interaction fixtures 2026-10-26 13:19:46 +00:00
Codex 9ed8bf76dd feat(plugins): bind third-party markets to isolated source identities 2026-10-26 13:15:52 +00:00
陈奇 91b8927c56 fix(plugin-themes): pin utility color aliases in security surfaces 2026-10-26 13:07:03 +00:00
陈奇 3cdc97782e wip(plugins): save selected-terminal sharing authority and contract 2026-10-26 13:04:31 +00:00
Codex 1e5e77fb9c merge: integrate plugin static distribution and capability theme stack 2026-10-26 12:34:54 +00:00
陈奇 f868221b5f fix(plugin-themes): validate text across shared neutral surfaces 2026-10-26 12:07:46 +00:00
陈奇 fd45463d98 test(transfers): avoid assuming concurrent file-preflight order 2026-10-26 12:07:46 +00:00
陈奇 fdeeaf74bb feat(plugins): add validated data-only host color themes 2026-10-26 11:46:08 +00:00
陈奇 ffabb9e9c7 Merge terminal sidebar navigation fix and projection regression 2026-10-26 11:17:24 +00:00
陈大猫 eb2c4b1611 fix(terminal): bound printable ZMODEM prefix echo delay (#3966) 2026-10-26 19:16:34 +08:00
陈奇 733f44cfdc test(plugin-sidebar): cover production bridge callback projection 2026-10-26 11:10:11 +00:00
陈奇 a8adeb86fe fix(plugin-sidebar): forward production terminal navigation actions 2026-10-26 11:06:50 +00:00
陈奇 0c097a1260 docs(plugins): record bounded host workflow native acceptance 2026-10-26 10:29:12 +00:00
陈奇 5c9286775e feat(plugins): host extension pages in terminal split panes 2026-10-26 10:27:00 +00:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

Fixes #3972
2026-10-26 18:13:38 +08:00
陈大猫 f300d583b2 fix(ssh): treat russh banners as single-channel (skip env) (#3974)
Fixes #3973

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈奇 618257e593 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages 2026-10-26 09:59:53 +00:00
陈奇 cde2be236e Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:59:43 +00:00
陈奇 342941b618 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages
# Conflicts:
#	electron/plugins/hostService.cjs
#	packages/plugin-cli/src/manifest.ts
#	packages/plugin-contract/src/index.ts
#	packages/plugin-sdk/src/index.ts
2026-10-26 09:59:42 +00:00
陈奇 8e4dd0c052 fix(plugins): pin every command to its activation runtime 2026-10-26 09:59:34 +00:00
陈奇 3a96cd2537 Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:56:42 +00:00
陈奇 0a692dfc72 fix(plugins): bound interactive commands and cancel orphan host prompts 2026-10-26 09:53:25 +00:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
陈奇 1d369bf322 fix(plugins): ignore withdrawn page layout failures 2026-10-26 09:45:04 +00:00
陈奇 0d77a2e56c feat(plugins): mount sandbox extension pages inside Vault 2026-10-26 09:32:00 +00:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
Codex d0e2c905cb feat(plugins): bind static reads to versioned snapshot sidecar 2026-10-26 09:13:45 +00:00
陈奇 5f06fd3cec Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:08:50 +00:00
陈奇 d4ca123999 fix(plugins): preserve input fidelity and native permission focus handoff 2026-10-26 09:06:53 +00:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
Codex c11cde9bb2 feat(plugins): use official static distribution for marketplace reads 2026-10-26 08:57:23 +00:00
陈奇 3ac4772087 chore(plugins): integrate reviewed manager polish with capability layers 2026-10-26 08:54:08 +00:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

hasExpectedPromptSuffix must match live PTY \r\r\n against a cached two-line idle prompt (CR→LF form) so cancel/completion matching works after readiness.

No release / no tag.
2026-10-26 16:39:38 +08:00
陈大猫 efe5d4530d fix(win): kill leftover Netcatty.exe before NSIS install (#3946) (#3962)
NSIS customInit taskkill /F /IM Netcatty.exe (no /T) so orphaned external-MCP Netcatty.exe cannot lock $INSTDIR during in-app update.

Fixes #3946
2026-10-26 16:30:56 +08:00
陈大猫 32e75b6ace fix(mcp): recognize two-line user@host idle prompts (#3961)
Fixes #3958. extractTrailingIdlePrompt now accepts two-line user@host[:path] + bare $/ # idle prompts (including CR-normalized live PTY form) so MCP terminal_execute getReadyPrompt no longer times out.

No release/tag.
2026-10-26 16:26:29 +08:00
陈奇 71d455d5de feat(plugins): add host text dialogs and explicit clipboard sharing 2026-10-26 08:05:02 +00:00
Codex 2838f90b03 refactor(plugins): unify information and notice entry 2026-10-26 07:54:33 +00:00
陈奇 ff3734d161 fix(plugins): refresh actual state after failed grant revocation 2026-10-26 07:39:37 +00:00
Codex 494470810a refactor(plugins): compact notices and align card headings 2026-10-26 07:26:38 +00:00
陈奇 185eac3849 fix(plugins): finish revocation cleanup and identify inactive saved grants 2026-10-26 07:18:21 +00:00
Codex bf91894ab5 fix(plugins): keep replacement impact beside install action 2026-10-26 07:17:06 +00:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
Codex b39f7a3244 feat(plugins): simplify manager guidance and use full-page details 2026-10-26 07:05:38 +00:00
陈奇 9af123e5d7 feat(plugins): inspect and revoke grants and align network lease contracts 2026-10-26 07:03:14 +00:00
Codex e5bd294cdd fix(plugins): validate and prepare registry snapshots before publication 2026-10-26 06:32:35 +00:00
Codex b48567d448 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client
# Conflicts:
#	application/i18n/locales/en/core.ts
#	application/i18n/locales/es/core.ts
#	application/i18n/locales/ru/core.ts
#	application/i18n/locales/zh-CN/core.ts
#	application/i18n/locales/zh-TW/core.ts
2026-10-26 06:27:10 +00:00
Codex 933bc93fc3 feat(plugins): enrich plugin cards with author and descriptions 2026-10-26 06:26:36 +00:00
Codex 45ed193579 feat(plugins): expose manual community catalog refresh 2026-10-26 06:19:26 +00:00
Codex f471a17a75 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:17:13 +00:00
Codex b9293a7bb5 fix(plugins): keep sparse installed grids compact 2026-10-26 06:16:55 +00:00
Codex 257660bcc1 fix(plugins): keep normalized README URLs pinned to reviewed commits 2026-10-26 06:15:49 +00:00
Codex eaf8c9a385 test(plugins): include registry source in catalog fixture 2026-10-26 06:10:18 +00:00
Codex ff42d0c1b9 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:09:32 +00:00
Codex 0adb7b822e feat(plugins): show installed plugins in a compact grid 2026-10-26 06:09:20 +00:00
Codex 311765eaa1 feat(plugins): read validated official registry snapshots 2026-10-26 06:06:26 +00:00
Codex d2a8398b7a fix(plugins): parent permission prompts to focused settings window 2026-10-26 06:01:13 +00:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
Codex 69307cee16 fix(plugins): validate settings fixture activation and polish manager controls 2026-10-26 05:49:24 +00:00
Codex 4445c292ea fix(plugins): bound icon archive reads and reject prototype glyph lookups 2026-10-26 05:45:53 +00:00
Codex 6fad272afe style(plugins): use theme-accent status pills for enable controls 2026-10-26 05:35:45 +00:00
Codex a7f8480da1 test(plugins): check packaging against the current workspace revision 2026-10-26 05:35:45 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
Codex e7e57f2a52 fix(plugins): align root workspace dependencies with API revision 2026-10-26 05:31:23 +00:00
Codex ab2f95837e test(plugins): add static settings view handoff fixture 2026-10-26 05:29:34 +00:00
Codex f3d04a1912 chore(plugins): pin reviewed Starter brand preview to source snapshot 2026-10-26 05:27:55 +00:00
Codex f4a6b205ad feat(plugins): add versioned offline brand icons and author guidance 2026-10-26 05:26:25 +00:00
Codex 614b7c997a fix(plugins): release manager modal before opening settings views 2026-10-26 05:21:00 +00:00
Codex aaef0d2ee1 feat(plugins): open management in a dedicated dialog 2026-10-26 05:13:37 +00:00
Codex 5128b51702 fix(plugins): soften selection colors and label historical errors 2026-10-26 04:13:20 +00:00
Codex f5c951f9a1 fix(plugins): restore narrow community browsing scroll position 2026-10-26 04:04:49 +00:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
Codex 8de33d9b5a feat(plugins): integrate responsive community list and detail layout 2026-10-26 04:02:33 +00:00
Codex c727cca643 fix(plugins): use managed catalog temp storage and lint browser example 2026-10-26 03:49:32 +00:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
Codex ba4fab5679 test(plugins): account for Starter runtime fixture build 2026-10-26 03:42:27 +00:00
Codex 27e5a10bfc chore(plugins): pin Starter panel example source 2026-10-26 03:41:29 +00:00
Codex 3efabf2b0f fix(plugins): expose a visible Starter panel and bundle shared browser helpers 2026-10-26 03:41:21 +00:00
Codex 74a2f99b08 fix(plugins): preserve sync sidecar coordinates on Node 22 2026-10-26 03:35:17 +00:00
Codex 80525d9b2d test(plugins): enforce shared policy and permission UI coverage in CI 2026-10-26 03:27:55 +00:00
Codex 741ea4e9c7 feat(plugins): pin the minimal starter and updated example documentation 2026-10-26 03:25:56 +00:00
Codex 4336f8fd53 feat(plugins): open browser-only settings with AI-style subpages and safe starter 2026-10-26 03:25:40 +00:00
Codex aa8989c515 Merge branch 'feat/plugin-settings-manager' into feat/plugin-curated-catalog 2026-10-26 03:15:25 +00:00
Codex b6b45fc11a fix(plugins): complete manager locale parity and local CLI assertions 2026-10-26 03:15:24 +00:00
Codex d5fd56ecdf feat(plugins): add curated browser-only catalog and safe README details 2026-10-26 03:10:48 +00:00
Codex d601d3d238 feat(plugins): add installed package management to settings 2026-10-26 02:59:36 +00:00
Codex e20f2a9986 fix(plugins): disable incompatible plugins before lazy contributions 2026-10-26 02:54:41 +00:00
Netcatty-Botandgithub-actions[bot] 0256e7471d Update Nix release metadata for v1.1.89 (#3931)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 09:45:44 +08:00
github-actions[bot]andgithub-actions[bot] a4b1009c2a chore: update star history charts [skip ci] (#3932)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-26 00:10:10 +00:00
陈大猫 54b6f85b09 fix(fonts): include SFMono Nerd Font in the terminal font picker (#3930)
Add `sfmono` to KNOWN_MONOSPACE_FONTS so glued typographic family names like "SFMono Nerd Font" appear in the Settings → Terminal font picker. Matching logic unchanged.

Fixes #3929
2026-10-25 22:46:09 +08:00
陈大猫 62798393c3 fix(mcp): send commands when a PowerShell prompt is only visible (#3928)
Fixes #3927

Gate: Codex thrash (mid-CSI truncate edge) after real P2 alt-screen fix; CI lint-and-test red only on unrelated TERM-SSHD flake; smoke PASS /workspace/netcatty-smoke-shots/pr-3928-20261025-223624.
2026-10-25 22:42:09 +08:00
FtLOi d382f7bd4f fix(ai): recognize Alpine default shell prompts (#3925)
Recognize Alpine ash default host:path prompts (e.g. localhost:~# ) so AI/pty exec readiness accepts idle Alpine sessions without changing PS1.

CI lint-and-test red on tip was unrelated: TERM-SSHD failed transfer releases the physical channel (transferBridge.termSshd.test.cjs). Local shellUtils + readiness suite and synthetic Alpine prompt smoke PASS.
2026-10-25 18:16:06 +08:00
陈大猫 ff36dc7bce fix(settings): truncate long Agent CLI paths in AI cards (#3924)
Fixes #3923

Settings content column and Agent CLI path rows use min-w-0 so long paths ellipsize inside the pane instead of overflowing the window.
2026-10-25 14:43:26 +08:00
陈大猫 d45fb75370 fix(settings): use in-app confirm for SFTP file association delete (#3921)
Replace native confirm() with ConfirmDialog when deleting an SFTP file association so Windows Settings inputs keep focus.

Fixes #3920
2026-10-25 13:12:04 +08:00
陈大猫 5a1b50a407 Merge pull request #3916 from binaricat/automation/nix-release-v1.1.88 2026-10-25 11:23:44 +08:00
copilot-swe-agent[bot]andbinaricat 0e7ba69edb Merge remote-tracking branch 'origin/main' into automation/nix-release-v1.1.88
# Conflicts:
#	nix/release.nix

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-10-25 03:22:33 +00:00
陈大猫 babf97e2c1 Merge pull request #3917 from difagume/fix/es-locale-parity 2026-10-25 11:21:59 +08:00
陈大猫 622ffec2cd Merge pull request #3913 from binaricat/automation/nix-release-v1.1.87 2026-10-25 11:21:09 +08:00
陈大猫 598de9cda0 fix(ssh): negotiate OpenSSH host certificates (Fixes #3918)
Offer OpenSSH host-certificate algorithms in ssh2 KEX so cert-only bastions (e.g. Instabox ssh-ed25519-cert-v01@openssh.com) can negotiate. Accept underlying key signature types, keep certs in legacy/skip-ECDSA lists, reject RSA cert signature downgrades, and normalize SK cert types before ECDSA verify.

Fixes #3918
2026-10-25 10:03:10 +08:00
Diego Fabricio 3c1afa9b7d fix(i18n): restore Spanish locale parity with English
The Spanish locale had drifted 163 keys behind English, so those strings fell back to English through the default-locale resolution in I18nProvider. English was also missing 42 of the 43 shortcut binding labels that DEFAULT_KEY_BINDINGS declares.

Add the missing Spanish messages for App Lock, FIDO2 security keys, Kubernetes statuses, host connection test, AI provider headers, OpenCode 2, keychain generation, launch at login, terminal layout and progress, and system manager runtime errors. Add the missing English shortcut labels using the canonical label field in DEFAULT_KEY_BINDINGS.

Harden the locale tests so this cannot drift silently again: include es and zh-TW in the App Lock coverage check, include en in the shortcut label check, and add a parity test that pins es to en for keys and interpolation placeholders while keeping the remaining gaps for ru, zh-CN and zh-TW tracked in an explicit pending list.
2026-10-24 13:20:16 -05:00
github-actions[bot] 36d9a7bd7f Update Nix release metadata for v1.1.88 2026-10-24 18:17:59 +00:00
陈大猫 c44633231a fix(mcp): recognize RHEL-family bash prompts as idle (#3915)
Extend POSIX_PROMPT_PATTERN for Oracle/RHEL/CentOS/Fedora [user@host path][#$] (incl. whitespace and ] in \W). Fixes #3914.
2026-10-25 02:01:23 +08:00
github-actions[bot] b1a13011a4 Update Nix release metadata for v1.1.87 2026-10-24 17:12:48 +00:00
陈大猫 631ce6c1d5 fix(serial): keep auto-login armed across wake Enter (#3908)
Wake-only CR/LF no longer permanently disables serial/telnet auto-login while the detector is active.

Fixes #3906
2026-10-25 00:54:09 +08:00
陈大猫 3e9ac6a36b fix(mcp): unpack re2js for external MCP bridge (#3911)
Unpack node_modules/re2js so external MCP bridge scripts under app.asar.unpacked can require it.

Fixes #3910
2026-10-25 00:43:20 +08:00
陈大猫 cbb1262955 fix(terminal): run host startup command after restored SSH reconnect (#3907)
Restored SSH tabs reconnect after app restart but skipped the host startup command because the boot effect closed over a pre-vault host snapshot. Prefer hostRef for SSH when the dial target matches.

Fixes #3905
2026-10-25 00:38:58 +08:00
陈大猫 7dcb52062f Merge pull request #3904 from binaricat/automation/nix-release-v1.1.86
Update Nix release metadata for v1.1.86
2026-10-24 20:50:07 +08:00
github-actions[bot] 2f1cc57c54 Update Nix release metadata for v1.1.86 2026-10-24 12:23:42 +00:00
c54df5e7d5 fix(terminal): resync side-panel refs after queued external writes (#3903)
A pending tool switch stored an intermediate focused-tool map in the ref.
An urgent open on another tab then updated only state, and the identity
guard never copied that tab back, so a later click on the old tool was
ignored. Committed entries now catch up without dropping a write that is
still ahead of the last render.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:57:00 +08:00
bb1d1ce8f7 fix(terminal): re-read hibernate keep count after eviction (#3902)
A keep-count change during victim drain was still applied from the count
captured when the leave started, so a zero setting could soft-hide the
leaving tab with no later timer to correct it. Retention now uses the live
count, and an already soft-hidden tab re-enters hibernate when the limit hits
zero.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:56:53 +08:00
e9e4ae576f fix(terminal): soft-hide the tab that just left when hibernate keep is full (#3900)
* fix(terminal): soft-hide the tab that just left when hibernate keep is full

Evict the oldest soft-hidden session to a full snapshot, and keep the
session the user just left on hideRuntimeOnly. Disconnected sessions,
alternate screen, and inline images keep their existing branches. Wake
replay order is unchanged.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): full-hibernate when keep-count eviction is refused

A soft-hidden session with inline images consumes the eviction request
and stays mounted. Soft-hiding the tab that just left anyway let that
protected session pin the oldest slot, so every later tab was retained
too. Walk oldest-first, keep the leaving tab soft-hidden only after one
victim actually leaves the set, and full-hibernate it when none can.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): retain a leaving tab only after eviction hibernates

Waking a soft-hidden victim clears its marker before fullHibernateRuntime
finishes. Treating that as success soft-hid the tab that just left even
when drain, snapshot, or alternate-screen failure restored the victim.
Await hibernate completion outside the pure keep-count helper, and put a
failed victim back in the soft-hidden set so it stays a candidate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): reserve hibernate slots outside domain policy

Keep the keep-count decision pure. The application walk reserves the
leaving tab before awaiting a victim, rechecks that slot before
soft-hide, and does not restore a victim that became visible or had
hibernate turned off while the upgrade was in flight.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:24:39 +08:00
2b0337bb55 docs(terminal): do not drop activeTabId from workspace ctx equality (#3899)
* docs(terminal): keep activeTabId in workspace ctx equality

Solo tab switches already leave TerminalPanesHost equal when the sessions
ref is unchanged. Reveal flush and repaint still run on the visible pane,
so removing activeTabId would not move the measured settle time.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* docs(terminal): use an ASCII arrow in the workspace ctx comment

AGENTS.md keeps comments ASCII-only. The keep-activeTabId note used a
Unicode arrow.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:24:17 +08:00
dee7912f8c perf(terminal): commit side-panel tool switches in one render (#3897)
* perf(terminal): commit side-panel tool switches in one render

Icon clicks write the focused tool and the layout together through
selectSidePanelTool. External open-tab writes still reconcile in an effect,
and that effect skips when the layout is already the selection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): merge pending side-panel writes across urgent renders

Icon switches inside startTransition used to publish absolute maps from
refs, and the next render copied the last committed state back over
those refs. A second tab's switch could then replace the queued map and
drop the first tab's selection and split. Patch each tab onto the queued
map, and leave a pending ref in place until that render commits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:24:11 +08:00
161c895240 perf(notes): skip tree reconcile when a retained panel hides (#3898)
Split the notes tree into a memo pane whose equality tracks notes, groups,
selection, editing, and expansion. Hide still flushes the draft through the
existing ref and leaves the editor mounted.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:24:07 +08:00
d2ce41cf1c perf(history): keep the history side panel mounted while hidden (#3896)
Notes and SFTP already park behind a hidden class. History returned null,
so each Notes to History switch remounted the virtual list. Keep that tree
mounted, subscribe to shell history only while it is visible, and remeasure
the list when it is shown again.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 14:29:27 +08:00
陈大猫 857d20db88 fix(sftp): retain completed uploads with backup cleanup warnings (#3895)
* fix(sftp): preserve successful uploads with backup cleanup warnings

Warn only on confirmed backup cleanup permission refusal after final publication. Preserve actionable warnings across transfer events, history restoration and completion ordering without masking deletion or commit failures.

Narrow follow-up to #3830; related #3879.

* fix(sftp): retain folder cleanup warnings before child completion
2026-10-24 13:20:45 +08:00
陈大猫 43d714b73c feat(serial): support local Windows named pipe consoles (#3894)
* feat(serial): support local Windows named pipe consoles

* test(serial): synchronize pipe acceptance and cover saved-host form

* fix(serial): preserve named-pipe paths with spaces when editing hosts

* test(serial): verify Windows pipes through native Electron IPC

* test(serial): keep Electron listener callbacks in renderer context

* test(serial): cover busy pipe cancellation and bounded slow-reader writes

* test(serial): construct single-instance fixture pipe path correctly
2026-10-24 12:56:58 +08:00
陈大猫 964e225f0f Merge pull request #3893 from binaricat/fix/pr3885-scoped-session-review
fix: complete scoped session opening and readiness follow-up
2026-10-24 11:31:42 +08:00
陈大猫 40ad9b2fff fix: retain connection history for agent workspace opens 2026-10-24 11:08:28 +08:00
陈大猫 b83a0459f3 fix: revalidate privacy before delivering pending PTY input 2026-10-24 10:58:27 +08:00
陈大猫 58418e887a fix: integrate scoped session opening and address review regressions
Preserve the original PR #3885 commits and integrate latest main.
Resolve local shell defaults, acknowledge committed workspace insertion, and preserve background readiness codes.
2026-10-24 10:47:15 +08:00
陈大猫 3e68f7cb3e fix(sftp): keep pending transfer quit checks fail closed (#3892)
* fix(sftp): serialize TERM-SSHD transfers on shared channels

* fix(sftp): serialize TERM-SSHD resume preflight reads

* fix(sftp): keep pending transfer quit checks fail closed

* test(sftp): use complete typed quit-guard fixtures
2026-10-24 10:36:35 +08:00
陈大猫 520c99da77 fix: preserve concurrent connection log updates across windows (#3891)
* fix: preserve concurrent connection log updates across windows

* fix: retire durable log metadata after partial replay writes
2026-10-24 10:35:51 +08:00
陈大猫 7eeb3d5724 fix(sftp): serialize TERM-SSHD transfers on shared channels (#3889)
* fix(sftp): serialize TERM-SSHD transfers on shared channels

* fix(sftp): serialize TERM-SSHD resume preflight reads
2026-10-24 10:35:49 +08:00
陈大猫 ed763c89b3 fix(tabs): activate sub-threshold tab clicks without cancelling dragstart (#3888)
Fixes #3886

Sub-threshold tab pointer moves no longer lose activate: dragstart is not cancelled; reorder still arms at ≥4px; mouseup/dragend activate when click was cancelled and no reorder started.
2026-10-23 23:34:57 +08:00
陈大猫 b884eff429 fix(tabs): prevent tab drag from swallowing close-button click (#3887)
Fixes #3886

Primary-button mousedown on plugin/editor/session/log-view top-tab close buttons now preventDefault+stopPropagation so HTML5 drag on the parent tab cannot swallow the click.
2026-10-23 20:41:48 +08:00
GOUKI9999 ade10ee985 docs: define scoped session continuity and bounded recovery guidance 2026-10-23 19:32:52 +08:00
GOUKI9999 cbcec92d32 test: expect scope failure when a deleted chat reuses a tab 2026-10-23 19:25:54 +08:00
GOUKI9999 5ded1f5308 fix: bind host sessions to workspaces and wait for shell prompts 2026-10-23 19:22:56 +08:00
陈大猫 a3b3e70388 Merge pull request #3884 from binaricat/fix/pr3883-force-refresh
feat(ai): LKG-first model catalogs with reliable manual refresh
2026-10-23 18:20:40 +08:00
陈大猫 e997d52418 fix(ai): bypass warm model cache on manual refresh 2026-10-23 18:04:22 +08:00
陈大猫 e9818a619c Merge pull request #3880 from binaricat/fix/mcp-single-channel-trust-encoding
fix(sftp): verified single-channel transfers with safe trust and encoding inheritance
2026-10-23 17:35:19 +08:00
GOUKI9999 02f8c798f7 feat(ai): hydrate model pickers from last-known-good and fetch only on demand
The list-models pipeline already served LKG on failure; this wires the
rest of the LKG-first, manual-refresh policy end to end.

Main: the list-models IPC accepts a force flag — manual refresh always
skips the in-memory cache and the persisted entry and goes live, and a
forced success overwrites the persisted catalog.

Renderer: the panel passes force only from the retry banner and the
picker's refresh button; panel-visibility and send-preflight loads stay
non-force and replay the renderer cache, so with a persisted catalog
main answers them without spending the live-fetch budget.

Picker: a footer refresh icon-button next to the status chip, visible
whenever any fetched catalog (live or last-known-good) or the degraded
warning is present; the spinner row remains the in-flight feedback.
Tooltip localized as ai.chat.refreshModelList in en/es/ru/zh-CN/zh-TW.

Fresh installs are unchanged (no persisted entry → live fetch as
before); existing users stop seeing jitter warnings on panel open and
pick up new CLI models via the refresh button.
2026-10-23 17:22:25 +08:00
GOUKI9999 45b0673ce8 fix(ai): clear the model catalog warning once a last-known-good catalog serves
A failed live catalog fetch serves the persisted last-known-good catalog
with a warning, and every TTL-expired automatic load re-attempted live —
so a backend whose discovery keeps failing re-armed the renderer's
「暂时无法更新实时模型列表」 banner forever, even though the persisted
models worked and selecting one never touched the banner state.

Serve the persisted last-known-good catalog without a live attempt on
every non-force load (the LKG-first policy, decided at the single
fetchSdkModelCatalog choke point): automatic loads now return a clean,
warning-less serve, so the next automatic load after the renderer's TTL
clears a stale banner. Warnings are reserved for an explicit force
refresh that fails, and for a first fetch with neither cache nor
persisted entry. The persisted current-id remap moves into a helper
shared by both serve paths.
2026-10-23 17:22:25 +08:00
陈大猫 f7daed70ee fix(sftp): preserve dedicated routing after saving host trust 2026-10-23 17:14:46 +08:00
651d474e01 fix(vault): treat empty vault_notes_get query as omitted (#3882)
Models pass query: "" on range reads. Accept that as no search so
vault_notes_get does not fail and retry.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 17:14:21 +08:00
陈大猫 6e12c314ec fix(sftp): discard TERM channel after uncertain CLOSE 2026-10-23 17:01:54 +08:00
陈大猫 97dfab225e fix(sftp): propagate dedicated dial trust and lifecycle failures
Do not fall back to a fresh GUI login when a dedicated connection rejects.
Capture capability selection before awaiting so source cleanup cannot change
the failure policy. Preserve ordinary reusable-channel fallback and cover
both paths with real bridge regression tests.
2026-10-23 16:40:52 +08:00
陈大猫 90b6569c7a fix(sftp): inherit encoding and accepted host identities for dedicated dials
Preserve PR #3875's original author commits and scoped TERM-SSHD transfer policy.
Copy persisted filename encoding into dedicated file connections. Capture only
host identities actually accepted by the terminal route, including jump hops,
and fail closed on later unverified changes without orphan host-key prompts.
Add first-trust, changed-key, route-scope and encoding regressions.
2026-10-23 16:30:42 +08:00
陈大猫 695fd27688 feat: add terminal context menu shortcut to open SFTP (#3878) 2026-10-23 14:41:06 +08:00
jichengheng fe7dbbf6b9 fix(sftp): select dedicated terminal dials during GUI reconnect 2026-10-23 14:03:03 +08:00
jichengheng 8afaf60c50 fix(sftp): await sequential verification handle cleanup 2026-10-23 12:53:30 +08:00
jichengheng 88a2019518 fix(sftp): verify sequential TERM-SSHD transfers before publication 2026-10-23 12:50:59 +08:00
jichengheng cb27b3d01b fix(mcp): isolate SFTP dials for single-channel SSH sessions 2026-10-23 12:08:52 +08:00
728 changed files with 53482 additions and 1474 deletions

No files matched your search

+15
View File
@@ -82,6 +82,8 @@ on:
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/serial-named-pipe.live.test.cjs"
- "scripts/fixtures/serial/**"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -251,6 +253,10 @@ jobs:
- name: Install deps
run: npm ci
- name: Test Windows serial pipe through native Electron
if: matrix.name == 'windows'
run: npx electron scripts/serial-named-pipe.live.test.cjs
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
@@ -324,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -625,6 +634,9 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -759,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
+20
View File
@@ -49,9 +49,21 @@ jobs:
- name: Verify Codex App Server schema
run: npm run check:codex-app-server-schema
- name: Verify plugin contract and runtime boundaries
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -91,6 +103,9 @@ jobs:
- name: Install deps
run: npm ci --ignore-scripts
- name: Exercise serial named pipe transport on Windows
run: node --test electron/bridges/serialNamedPipe.test.cjs
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
@@ -135,5 +150,10 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+6 -3
View File
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
## Plugin host runtime
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
The plugin host under `electron/plugins/` exposes browser-only packages in normal
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
granting their permissions. Admission and default placement share
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
types still come only from `packages/plugin-contract/schema/`; do not add a
second private RPC shape when extending the host.
+6
View File
@@ -12,6 +12,9 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -86,10 +89,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
+215 -1
View File
@@ -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,
+23 -5
View File
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -424,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
@@ -1164,6 +1180,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1183,6 +1200,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog, Host } from '../../domain/models';
import { createLocalShellHost } from '../../domain/localShellHost';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
const connectionLogs: ConnectionLog[] = [];
let appendedHost: Host | undefined;
const getCtx = () => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
connectToHost: (candidate: Host) => {
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
appendedHost = candidate;
return 'workspace-session';
},
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
connectionLogs.push({ ...entry, id: 'workspace-log' });
},
});
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
assert.equal(appendedHost?.localShell, '/bin/zsh');
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
assert.equal(appendedHost?.localStartDir, '/tmp/work');
assert.equal(connectionLogs.length, 1);
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
assert.equal(connectionLogs[0].hostId, host.id);
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
assert.equal(connectionLogs[0].username, 'local-user');
assert.equal(connectionLogs[0].protocol, 'local');
assert.equal(connectionLogs[0].localHostname, 'local-machine');
const updates: Array<{ id: string; terminalData: string }> = [];
await handleTerminalDataCaptureImpl(() => ({
IS_DEV: false,
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
// A newer, same-host log must not steal this session's capture.
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
selectConnectionLogForTerminalDataCapture,
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
}), 'workspace-session', 'captured terminal output');
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
});
for (const protocol of ['local', 'ssh', 'serial'] as const) {
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
let logs = 0;
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
const sessionId = handleConnectToHostImpl(() => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
connectToHost: () => null,
addConnectionLog: () => { logs += 1; },
}), host);
assert.equal(sessionId, null);
assert.equal(logs, 0);
});
}
+14 -8
View File
@@ -56,6 +56,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
@@ -255,6 +256,7 @@ export function AppSideEffects() {
createLocalTerminal,
createSerialSession,
connectToHost,
appendHostToWorkspaceConfirmed,
closeSession,
closeSessions,
closeWorkspace,
@@ -714,7 +716,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -1458,7 +1460,7 @@ export function AppSideEffects() {
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
@@ -1476,7 +1478,9 @@ export function AppSideEffects() {
: resolveEffectiveHost;
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
connectToHost: workspaceId
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
: connectToHost,
discoveredShells,
host: connectionHost,
identities,
@@ -1487,7 +1491,7 @@ export function AppSideEffects() {
systemInfoRef,
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1497,12 +1501,14 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
if (target.error) return { ok: false as const, error: target.error };
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
// host_open always opens a visible tab, as documented.
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
const sessionId = handleConnectToHost(host, true, hidden);
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${host.id}".` };
}
@@ -1511,8 +1517,8 @@ export function AppSideEffects() {
if (readExternalMcpFocusOnHostOpen()) {
void netcattyBridge.get()?.openMainWindow?.();
}
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
}, [handleConnectToHost, workspaces]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
+6 -1
View File
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -167,7 +169,8 @@ function AppPluginKeybindingThemeHost({
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
// View environment is global in the current host: publish committed colors only.
const appThemeStyle = useAppThemeStyle(false);
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
@@ -855,6 +858,8 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
@@ -0,0 +1,43 @@
// A separate renderer thread lets the real main-process timeout run while
// window.confirm is synchronously blocked, just like Electron's native dialog.
require("tsx/cjs");
const { parentPort, workerData } = require("node:worker_threads");
const { EventEmitter } = require("node:events");
const React = require("react");
const { act, create } = require("react-test-renderer");
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
const signal = new Int32Array(workerData.signal);
let resumes = 0;
global.IS_REACT_ACT_ENVIRONMENT = true;
global.window = {
confirm() {
parentPort.postMessage({ type: "prompt" });
Atomics.wait(signal, 0, 0, 10000);
return workerData.accept;
},
};
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
sftpTransferCenterStore.pause = async () => {};
sftpTransferCenterStore.resume = async () => { resumes++; };
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
netcattyBridge.get = () => ({
onCheckDirtyEditors(callback) {
return preload.onCheckDirtyEditors((request) => {
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
});
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
});
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
return null;
}
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
void act(async () => { create(React.createElement(Probe)); })
.then(() => parentPort.postMessage({ type: "ready" }));
@@ -59,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -143,6 +144,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -171,6 +173,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
@@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { useAppStartupEffects } from './useAppStartupEffects.ts';
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
const require = createRequire(import.meta.url);
const React = require('react');
const { act, create } = require('react-test-renderer');
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
const { createPreloadApi } = require('../../electron/preload/api.cjs');
// No Electron UI, sockets, real transfers, or preferences. Only the network
// pause completion is held. Actual renderer hook, window close callback, and
// dirty-query helper run together with the actual 5000ms production deadline.
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let confirmations = 0, reports = 0, resumeCalls = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: any, releasePause: any;
const queryPromises: Promise<void>[] = [];
const pause = new Promise<void>(resolve => { releasePause = resolve; });
if (!slowPause) releasePause();
const ipcMain = new EventEmitter();
let win: any;
t.after(async () => {
releasePause(); await Promise.allSettled(queryPromises);
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
t.mock.method(netcattyBridge, 'get', () => ({
onCheckDirtyEditors(callback: any) {
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
class Window extends EventEmitter {
destroyed = false;
webContents = Object.assign(new EventEmitter(), {
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
setWindowOpenHandler() {}, openDevTools() {},
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
});
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
queryDirtyEditors(wc: any, timeout: number, options: any) {
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
return queryDirtyEditors(wc, timeout, options);
},
});
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
electronDir: '/unused/electron', registerAsMainWindow: true,
});
win.close();
assert.equal(win.destroyed, false, 'first close is correctly guarded');
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
if (!slowPause) {
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
return;
}
assert.equal(confirmations, 0, 'slow pause prevents prompt');
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
releasePause(); await Promise.allSettled(queryPromises);
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
assert.equal(win.destroyed, false);
assert.equal(resumeCalls, 1);
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
});
@@ -0,0 +1,52 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { Worker } = require("node:worker_threads");
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
for (const accept of [false, true]) {
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
const signal = new SharedArrayBuffer(4);
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
workerData: { accept, signal },
});
const ipcMain = new EventEmitter();
const events = new EventEmitter();
const replies = [];
let promptShown = false;
let closed = false;
const wc = {
isDestroyed: () => closed,
isCrashed: () => false,
send(channel, payload) { worker.postMessage({ channel, payload }); },
};
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
worker.on("message", (message) => {
if (message.type === "reply") {
replies.push(message.payload);
ipcMain.emit(message.channel, { sender: wc }, message.payload);
} else {
if (message.type === "prompt") promptShown = true;
events.emit(message.type, message);
}
});
worker.on("error", (error) => events.emit("error", error));
await once(events, "ready");
const started = Date.now();
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
if (!result) closed = true;
assert.equal(promptShown, true);
assert.equal(result, "transfers");
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
const done = once(events, "done");
Atomics.store(new Int32Array(signal), 0, 1);
Atomics.notify(new Int32Array(signal), 0);
const [completed] = await done;
assert.equal(completed.resumes, 1);
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
assert.equal(replies.at(-1).reason, "transfers");
assert.equal(closed, false);
});
}
@@ -1,11 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { TransferTask } from "../../domain/models";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 10,
transferredBytes: 2,
speed: 1,
startTime: 1,
isDirectory: false,
});
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
@@ -13,7 +30,8 @@ for (const accept of [false, true]) {
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
g.window.confirm = () => accept;
let prompts = 0;
g.window.confirm = () => { prompts++; return accept; };
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
@@ -76,12 +94,159 @@ for (const accept of [false, true]) {
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
// Dedicated resume promises can represent the whole transfer lifetime.
// A second close must still prompt while those promises remain pending.
await query();
assert.equal(prompts, 2);
assert.equal(resumeSettled, false);
assert.equal(reports.length, 4);
}
});
}
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let prompts = 0;
let resumes = 0;
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: {
confirm() { prompts++; now = 6000; return true; },
} });
t.after(async () => {
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", async () => {
if (outcome === "pause-rejected") throw new Error("pause failed");
if (outcome === "pause-expired") now = 6000;
});
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
replies.push({ dirty, reason, ...options });
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
await query({ requestId: outcome, deadline: 5000 });
assert.deepEqual(replies, [
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
{ dirty: true, reason: "transfers", requestId: outcome },
]);
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
assert.equal(resumes, 1);
});
}
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let cancelled = false;
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
cancelled = true;
await query({ requestId: "after-cancel", deadline: 11000 });
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
const replyCount = replies.length;
releasePause();
await first;
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
assert.equal(prompts, 0);
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
});
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let status: TransferTask["status"] = "transferring";
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
const retry = query({ requestId: "retry", deadline: 11000 });
status = "paused";
releasePause();
await Promise.all([first, retry]);
assert.equal(prompts, 1);
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
});
+49 -11
View File
@@ -344,12 +344,26 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(async () => {
const unsub = bridge.onCheckDirtyEditors(async (request) => {
const response = { requestId: request?.requestId };
// Another close request must not start a second pause/prompt while the
// first attempt is still pausing or awaiting a decision.
const previousAttempt = transferQuitPendingRef.current;
if (previousAttempt && Date.now() < previousAttempt.deadline) {
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
return;
}
// An expired pause may never settle. Let a retry re-check current tasks
// (including a user's cancellation), without granting the old attempt
// permission to prompt or resume over that newer decision.
let attempt: typeof previousAttempt = null;
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
@@ -374,24 +388,44 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
|| previousAttempt?.resumeTaskIds.has(task.id)
));
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
// A slow pause must never turn the main-process timeout into
// permission to quit before the user has made a decision.
hasDirty = true;
reason = "transfers";
tasksToResume = resumable;
// A retry inherits the still-unfinished rows the expired attempt
// auto-paused. Their current status may already be pausing/paused.
attempt = {
deadline: request?.deadline ?? Date.now() + 5000,
resumeTaskIds: new Set(resumable.map((task) => task.id)),
};
transferQuitPendingRef.current = attempt;
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
if (transferQuitPendingRef.current !== attempt) return;
const expired = () => request && Date.now() >= request.deadline;
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
// The native prompt can itself outlive the main-process deadline.
// An expired acceptance is not permission for a later close attempt.
if (expired()) hasDirty = true;
}
if (!hasDirty) {
reason = undefined;
tasksToResume = [];
}
} else {
// All prior work was finished/cancelled; invalidate its late pause.
transferQuitPendingRef.current = null;
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
@@ -399,8 +433,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
// Report refusal before recovery, which may itself take a long time.
// A resume promise can span the entire transfer, so do not hold the
// close gate until it settles. A later close must be able to pause again.
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
});
return unsub;
}, [enabled, t]);
+6 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
import type React from 'react';
import { useMemo } from 'react';
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
const pluginThemeRevision = usePluginThemeRevision();
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
includePreview,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
}
+1
View File
@@ -582,6 +582,7 @@ export const enAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.refreshModelList': 'Refresh model list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+215 -3
View File
@@ -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',
@@ -964,9 +990,53 @@ Highlight the focused split pane:
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
@@ -1191,8 +1261,135 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.statusUnknown": "Status unknown",
"plugins.sidebar.extensions": "Extension pages",
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
"plugins.sidebar.loading": "Loading extension page…",
"plugins.vault.loading": "Loading plugin page…",
"plugins.vault.extensions": "Extensions",
"plugins.vault.back": "Back to hosts",
"plugins.interaction.title": "Plugin request",
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
"plugins.interaction.input": "Input text",
"plugins.interaction.filter": "Filter choices",
"plugins.interaction.preview": "Preview clipboard",
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
"plugins.interaction.previewAction": "Preview clipboard",
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
"settings.plugins.permissions.allow": "Allow",
"settings.plugins.permissions.ask": "Ask",
"settings.plugins.permissions.deny": "Deny",
"settings.plugins.permissions.title": "Permissions",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading permissions...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.themes.title": "Plugin themes",
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
"settings.plugins.themes.choose": "Choose a theme",
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
"settings.plugins.themes.applied": "Applied",
"settings.plugins.themes.preview": "Preview",
"settings.plugins.themes.apply": "Apply",
"settings.plugins.themes.cancel": "Cancel preview",
"settings.plugins.themes.restore": "Restore default",
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
"settings.plugins.themes.failed": "Could not apply theme.",
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.status": "Notices: {count}",
"settings.plugins.info": "Plugin information",
"settings.plugins.manager.noVaultShort": "Secret storage unavailable",
"settings.plugins.market.offlineShort": "Offline catalog · installation unavailable",
"settings.plugins.market.readmeInfo": "About this README",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
"settings.plugins.market.back": "All plugins",
"settings.plugins.market.install": "Install this version (disabled)",
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
"settings.plugins.market.emptyReadme": "This README is empty.",
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
@@ -1221,5 +1418,20 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': '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.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
};
+1
View File
@@ -879,6 +879,7 @@ export const enTerminalMessages: Messages = {
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
+4
View File
@@ -370,6 +370,7 @@ export const enVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.pathConflict.title': 'Transfer blocked',
@@ -974,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+21
View File
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -565,6 +582,7 @@ export const esAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
@@ -693,6 +711,9 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
+255 -2
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
@@ -84,6 +110,32 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -119,6 +171,65 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -1150,8 +1261,135 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
"plugins.sidebar.extensions": "Páginas de extensiones",
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
"plugins.sidebar.loading": "Cargando página de extensión…",
"plugins.vault.loading": "Cargando página del plugin…",
"plugins.vault.extensions": "Extensiones",
"plugins.vault.back": "Volver a hosts",
"plugins.interaction.title": "Solicitud de complemento",
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
"plugins.interaction.input": "Introducir texto",
"plugins.interaction.filter": "Filtrar opciones",
"plugins.interaction.preview": "Vista previa del portapapeles",
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
"plugins.interaction.previewAction": "Vista previa del portapapeles",
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
"settings.plugins.permissions.allow": "Permitir",
"settings.plugins.permissions.ask": "Preguntar",
"settings.plugins.permissions.deny": "Denegar",
"settings.plugins.permissions.title": "Permisos",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.themes.title": "Temas de plugins",
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
"settings.plugins.themes.choose": "Elegir tema",
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
"settings.plugins.themes.applied": "Aplicado",
"settings.plugins.themes.preview": "Vista previa",
"settings.plugins.themes.apply": "Aplicar",
"settings.plugins.themes.cancel": "Cancelar vista previa",
"settings.plugins.themes.restore": "Restaurar predeterminado",
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.status": "{count} avisos",
"settings.plugins.info": "Información de plugins",
"settings.plugins.manager.noVaultShort": "Almacenamiento de secretos no disponible",
"settings.plugins.market.offlineShort": "Catálogo sin conexión · instalación no disponible",
"settings.plugins.market.readmeInfo": "Acerca de este README",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
"settings.plugins.market.back": "Todos los plugins",
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
"settings.plugins.market.emptyReadme": "Este README está vacío.",
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
@@ -1180,5 +1418,20 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': '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.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
};
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
+41
View File
@@ -219,6 +219,7 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -675,6 +676,45 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
@@ -841,6 +881,7 @@ export const esTerminalMessages: Messages = {
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
+16
View File
@@ -370,6 +370,7 @@ export const esVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
@@ -964,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
@@ -1043,6 +1047,18 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
+1
View File
@@ -521,6 +521,7 @@ export const ruAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.refreshModelList': 'Обновить список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
+140 -2
View File
@@ -1130,8 +1130,131 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
"plugins.sidebar.extensions": "Страницы расширений",
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
"plugins.sidebar.loading": "Загрузка страницы расширения…",
"plugins.vault.loading": "Загрузка страницы плагина…",
"plugins.vault.extensions": "Расширения",
"plugins.vault.back": "К хостам",
"plugins.interaction.title": "Запрос плагина",
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
"plugins.interaction.input": "Ввести текст",
"plugins.interaction.filter": "Фильтр вариантов",
"plugins.interaction.preview": "Предпросмотр буфера обмена",
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.themes.title": "Темы плагинов",
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
"settings.plugins.themes.choose": "Выберите тему",
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
"settings.plugins.themes.applied": "Применено",
"settings.plugins.themes.preview": "Предпросмотр",
"settings.plugins.themes.apply": "Применить",
"settings.plugins.themes.cancel": "Отменить предпросмотр",
"settings.plugins.themes.restore": "Сбросить тему",
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
"settings.plugins.themes.failed": "Не удалось применить тему.",
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.status": "Уведомления: {count}",
"settings.plugins.info": "Информация о плагинах",
"settings.plugins.manager.noVaultShort": "Хранилище секретов недоступно",
"settings.plugins.market.offlineShort": "Каталог офлайн · установка недоступна",
"settings.plugins.market.readmeInfo": "Об этом README",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
"settings.plugins.market.back": "Все плагины",
"settings.plugins.market.install": "Установить эту версию (отключённой)",
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
"settings.plugins.market.emptyReadme": "Этот README пуст.",
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
@@ -1160,4 +1283,19 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
};
+1
View File
@@ -886,6 +886,7 @@ export const ruTerminalMessages: Messages = {
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
+4
View File
@@ -413,6 +413,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
@@ -1013,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en";
import es from "./es";
import ru from "./ru";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
test("folder cleanup-warning counts are localized in every supported locale", () => {
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
}
});
+1
View File
@@ -582,6 +582,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.refreshModelList': '刷新模型列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
+171 -2
View File
@@ -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': '取消',
@@ -1035,6 +1061,7 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.pathConflict.title': '无法开始传输',
@@ -1149,8 +1176,135 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "状态未知",
"plugins.sidebar.extensions": "扩展页面",
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
"plugins.sidebar.loading": "正在加载扩展页面…",
"plugins.vault.loading": "正在加载插件页面…",
"plugins.vault.extensions": "扩展",
"plugins.vault.back": "返回主机",
"plugins.interaction.title": "插件请求",
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
"plugins.interaction.input": "输入文本",
"plugins.interaction.filter": "筛选选项",
"plugins.interaction.preview": "剪贴板预览",
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
"plugins.interaction.previewAction": "预览剪贴板",
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
"plugins.interaction.terminalTarget": "所选终端",
"plugins.interaction.terminalPreview": "预览终端选区",
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
"plugins.interaction.draft": "终端输入草稿",
"plugins.interaction.exactText": "转义控制字符后的完整文本",
"plugins.interaction.closeDraft": "关闭草稿",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
"settings.plugins.permissions.allow": "允许",
"settings.plugins.permissions.ask": "询问",
"settings.plugins.permissions.deny": "拒绝",
"settings.plugins.permissions.title": "权限",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在加载权限...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.themes.title": "插件主题",
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
"settings.plugins.themes.choose": "选择主题",
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
"settings.plugins.themes.applied": "已应用",
"settings.plugins.themes.preview": "预览",
"settings.plugins.themes.apply": "应用",
"settings.plugins.themes.cancel": "取消预览",
"settings.plugins.themes.restore": "恢复默认",
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
"settings.plugins.themes.failed": "无法应用主题。",
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.status": "{count} 项提醒",
"settings.plugins.info": "插件说明",
"settings.plugins.manager.noVaultShort": "凭据存储不可用",
"settings.plugins.market.offlineShort": "目录离线 · 暂不可安装",
"settings.plugins.market.readmeInfo": "README 来源说明",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安装此版本(保持停用)",
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
"settings.plugins.market.emptyReadme": "README 内容为空。",
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
@@ -1179,5 +1333,20 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
};
@@ -969,6 +969,7 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
+3
View File
@@ -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': '通过主机代理',
+1
View File
@@ -572,6 +572,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.refreshModelList': '重新整理模型清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
+171 -2
View File
@@ -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': '取消',
@@ -1037,6 +1063,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.pathConflict.title': '無法開始傳輸',
@@ -1152,9 +1179,136 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "狀態不明",
"plugins.sidebar.extensions": "擴充功能頁面",
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
"plugins.vault.loading": "正在載入擴充功能頁面…",
"plugins.vault.extensions": "擴充功能",
"plugins.vault.back": "返回主機",
"plugins.interaction.title": "外掛請求",
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
"plugins.interaction.input": "輸入文字",
"plugins.interaction.filter": "篩選選項",
"plugins.interaction.preview": "剪貼簿預覽",
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
"plugins.interaction.previewAction": "預覽剪貼簿",
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
"plugins.interaction.terminalTarget": "所選終端",
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
"plugins.interaction.draft": "終端輸入草稿",
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
"plugins.interaction.closeDraft": "關閉草稿",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
"settings.plugins.permissions.allow": "允許",
"settings.plugins.permissions.ask": "詢問",
"settings.plugins.permissions.deny": "拒絕",
"settings.plugins.permissions.title": "權限",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在載入權限...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.themes.title": "外掛主題",
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
"settings.plugins.themes.choose": "選擇主題",
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
"settings.plugins.themes.applied": "已套用",
"settings.plugins.themes.preview": "預覽",
"settings.plugins.themes.apply": "套用",
"settings.plugins.themes.cancel": "取消預覽",
"settings.plugins.themes.restore": "恢復預設",
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
"settings.plugins.themes.failed": "無法套用主題。",
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.status": "{count} 項提醒",
"settings.plugins.info": "外掛說明",
"settings.plugins.manager.noVaultShort": "憑據儲存無法使用",
"settings.plugins.market.offlineShort": "目錄離線 · 暫時無法安裝",
"settings.plugins.market.readmeInfo": "README 來源說明",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
'settings.plugins.pressKeybinding': '請按下組合鍵',
@@ -1182,4 +1336,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
};
@@ -969,6 +969,7 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.flowControl': '流量控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬體',
'serial.type.pseudo': '虛擬終端',
+3
View File
@@ -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': '透過主機代理',
+14 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
@@ -145,3 +145,16 @@ test("a closing destination rejects a popout without releasing source content",
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
test("detached editor dirty-check replies echo the main-process request", (t) => {
let query!: (request: { requestId: string; deadline: number }) => void;
const replies: unknown[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
}));
t.after(subscribeEditorWindowDirtyCheck(() => true));
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
});
+2 -2
View File
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors(() => {
return bridge.onCheckDirtyEditors((request) => {
let hasDirty = false;
try {
hasDirty = cb() === true;
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
} catch {
// ignore
}
+255
View File
@@ -0,0 +1,255 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
+53
View File
@@ -0,0 +1,53 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -0,0 +1,102 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -0,0 +1,34 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { Terminal } from '@xterm/xterm';
function fixture() {
let reads = 0;
const term = { cols: 80, element: { isConnected: true },
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 3, y: 0 } }),
getSelection() { reads++; return 'raw'; },
buffer: { active: { getLine: () => ({ length: 3, translateToString: () => 'normalized' }) } },
} as unknown as Terminal;
const state = { term, sessionId: 'a', bootEpoch: 1, visible: true, isFocusedPane: true, connected: true, sensitive: false, normalize: false, label: 'Synthetic A' };
const source = createPluginTerminalSelectionSource(() => state, 'renderer');
return { term, state, source, get reads() { return reads; } };
}
test('registered reader honors explicit false normalization and reads only on Preview', () => {
const f = fixture();
const cleanup = registerTerminalSelectionSource('a', f.source);
try {
assert.equal(f.reads, 0);
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'raw');
assert.equal(f.reads, 1);
f.state.normalize = true;
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'normalized');
} finally { cleanup(); }
});
test('separate terminal references, hidden/attached renderer, reconnect and empty selection have no fallback', () => {
const a = fixture(), b = fixture(); b.state.sessionId = 'b';
const cleanupA = registerTerminalSelectionSource('a', a.source), cleanupB = registerTerminalSelectionSource('b', b.source);
try {
assert.throws(() => readPluginTerminalSelection('wrong', 'renderer:1'), /no longer/);
a.term.getSelection = () => '';
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /between 1/);
assert.equal(b.reads, 0);
a.state.bootEpoch++;
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /no longer/);
for (const field of ['visible', 'isFocusedPane', 'connected'] as const) {
a.state[field] = false;
assert.equal(a.source.target(), null);
a.state[field] = true;
}
a.state.sensitive = true;
assert.equal(a.source.target(), null);
a.state.sensitive = false;
Object.defineProperty(a.term.element, 'isConnected', { value: false });
assert.equal(a.source.target(), null);
} finally { cleanupA(); cleanupB(); }
assert.throws(() => readPluginTerminalSelection('b', 'renderer:1'), /no longer/);
});
test('oversized ranges refuse to materialize selection and UTF-8 bytes are bounded', () => {
const f = fixture(), cleanup = registerTerminalSelectionSource('a', f.source);
try {
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 100 } });
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /bounded/);
assert.equal(f.reads, 0);
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 0 } });
f.term.getSelection = () => '\u20ac'.repeat(1366);
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /4096 bytes/);
} finally { cleanup(); }
});
test('real pane snapshots give sharing ownership to solo, Focus mode, and only the selected split pane', async () => {
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../../components/terminalPaneVisibility');
const workspaceById = new Map<string, import('../../types').Workspace>([
['split', { id: 'split', title: 'Split', viewMode: 'split', focusedSessionId: 'split-a', root: { id: 'split-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'split-a' }, { id: 'b', type: 'pane', sessionId: 'split-b' }] } }],
['focus', { id: 'focus', title: 'Focus', viewMode: 'focus', focusedSessionId: 'focus-a', root: { id: 'focus-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'focus-a' }, { id: 'b', type: 'pane', sessionId: 'focus-b' }] } }],
]);
const cases = [
{ name: 'solo', activeTabId: 'solo', sessionId: 'solo', eligible: true },
{ name: 'inactive solo', activeTabId: 'solo', sessionId: 'other', eligible: false },
{ name: 'Focus mode', activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
{ name: 'other Focus pane', activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
{ name: 'selected split', activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
{ name: 'other visible split', activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
{ name: 'inactive workspace', activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
{ name: 'hidden layer', activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
];
for (const item of cases) {
const snapshot = getTerminalPaneRenderSnapshot({ ...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true });
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(snapshot));
const f = fixture();
const state = { ...f.state, sessionId: item.sessionId, visible: inputProps.isVisible,
isFocusedPane: inputProps.isFocusedPane };
const source = createPluginTerminalSelectionSource(() => state, 'derived-pane');
assert.equal(source.target() !== null, item.eligible, `${item.name}: ${snapshot}`);
if (item.name === 'solo' || item.name === 'Focus mode') assert.equal(inputProps.isFocused, false, 'do not redefine the legacy focus bit');
}
});
+27
View File
@@ -0,0 +1,27 @@
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
export interface PluginVaultPage {
key: `plugin-vault:${string}`;
pluginId: string;
pluginName: string;
version: string;
viewId: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
return plugins.flatMap((plugin) => plugin.views
.filter((view) => view.location === 'vault-page' && view.visible)
.map((view): PluginVaultPage => ({
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
})))
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
}
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
return pages.find((page) => page.key === section) ?? null;
}
+18
View File
@@ -0,0 +1,18 @@
/** Native child views must yield to host dialogs and overlapping portals. */
export function isPluginViewOccluded(mount: HTMLElement): boolean {
const rect = mount.getBoundingClientRect();
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
const overlays = mount.ownerDocument.querySelectorAll(
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
);
return [...overlays].some((overlay) => {
if (mount.contains(overlay)) return false;
const style = getComputedStyle(overlay);
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
const other = overlay.getBoundingClientRect();
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
&& other.y < rect.bottom && other.bottom > rect.y;
});
}
@@ -91,6 +91,7 @@ test('session actions register and unregister', () => {
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendHostToWorkspaceConfirmed: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
@@ -57,6 +57,7 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
@@ -1,3 +1,4 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
@@ -163,6 +163,9 @@ export async function restoreSftpTransferHistoryCooperatively(
const child = parentChildren[childIndex];
if (
child.status === "completed"
// Match canonical pruning: warning rows carry recovery paths that
// cannot be represented by the directory's compact manifest.
&& !child.warning
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
@@ -135,6 +135,34 @@ test("completed dispatch consumes only exact identity and never restarts", async
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
store.upsertTasks([root, task]);
t.after(() => store.dismiss(root.id));
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
assert.equal(result?.warning, warning);
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
assert.equal(store.getTask(task.id)?.warning, warning);
});
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => store.dismiss(task.id));
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
+1
View File
@@ -65,6 +65,7 @@ export interface FileWatchSyncedEvent {
localPath: string;
remotePath: string;
bytesWritten: number;
warning?: string;
}
export interface FileWatchErrorEvent {
@@ -156,6 +156,10 @@ export const createUploadTaskCallbacks = ({
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
});
},
onTaskWarning: (taskId: string, warning: string) => {
// Preserve cleanup details before completion can compact a child row.
store.patchTask(taskId, { warning });
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
store.patchTask(taskId, {
status: "completed" as TransferStatus,
+12 -2
View File
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
@@ -37,7 +37,17 @@ export async function runTransferAndWaitForOwner(
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) return result;
if (!result?.superseded) {
// The invoke response may arrive before completed IPC. Preserve cleanup
// details before the caller completes (and may compact) this child.
if (result?.warning && !result.error && !result.cancelled) {
const latest = sftpTransferCenterStore.getTask(task.id);
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
}
}
return result;
}
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
@@ -33,6 +33,85 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
]);
});
test("completion cleanup warnings survive owner snapshots and restored history", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = makeTask("cleanup-warning");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
assert.equal(restored.getTask(source.id)?.status, "completed");
assert.equal(restored.getTask(source.id)?.error, undefined);
});
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-snapshot");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
});
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-event");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
});
test("late completion enriches a locally completed task with its cleanup warning", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const endTime = Date.now();
store.publishOwner("panel", [source]);
// The invoke response can settle the renderer before the completed IPC arrives.
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
let notifications = 0;
const unsubscribe = store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
unsubscribe();
});
for (const status of ["failed", "cancelled"] as const) {
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
const store = createSftpTransferCenterStore();
const endTime = Date.now();
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
store.publishOwner("panel", [task]);
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
assert.equal(store.getTask(task.id)?.status, status);
assert.equal(store.getTask(task.id)?.error, "original outcome");
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
const calls: string[] = [];
const store = createSftpTransferCenterStore();
@@ -3576,6 +3655,46 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
});
for (const indexed of [false, true]) {
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
const now = Date.now();
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
const children: TransferTask[] = [0, 1].map((index) => ({
...makeTask(`restore-warning-child-${index}`, "completed"),
parentTaskId: parent.id, endTime: now,
warning: index === 0 ? warning : undefined,
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
}));
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for cleanup-warning history migration"));
}, 10_000);
const check = () => {
if (!store.getTask(parent.id)) return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
});
}
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
const legacyOnly = makeTask("legacy-only", "paused");
const staleCollision = makeTask("collision", "paused");
@@ -152,6 +152,7 @@ export interface SftpTransferCenterStore {
startedAt?: number;
endedAt?: number;
error?: string;
warning?: string;
transferred?: number;
totalBytes?: number;
speed?: number;
@@ -289,6 +290,12 @@ export function mergeOwnerPublishedTask(
updatedAt: incoming.updatedAt ?? Date.now(),
};
// A panel snapshot may predate cleanup metadata returned by the invoke or
// completed IPC. Keep it through this attempt, but not a restart or failure.
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
merged.warning = incoming.warning ?? existing.warning;
}
if (incomingReset || incomingTerminal) {
return merged;
}
@@ -2274,6 +2281,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (terminal && event.type !== "cancelled") {
// Allow a late explicit cancel to stick; ignore everything else.
if (event.type === "completed" || event.type === "failed") {
// An invoke response can complete the renderer row before its IPC
// event arrives. Enrich that same success with cleanup metadata,
// without replacing any terminal outcome or reviving stopped work.
if (existing.status === "completed" && event.type === "completed"
&& event.warning && event.warning !== existing.warning) {
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
emit(true);
return;
}
// Keep existing terminal state (prefer cancelled over late completed).
publishRepairedHierarchy();
return;
@@ -2561,6 +2577,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
? task.checkpointBytes
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
error: event.error,
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
endTime: event.endedAt ?? Date.now(),
speed: 0,
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
@@ -9,6 +9,106 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
const sessions = ["images", "plain", "other"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("images");
terminalHiddenRendererStore.markSoftHidden("plain");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
terminalHiddenRendererStore.clearSoftHidden("plain");
terminalHiddenRendererStore.settleEviction("plain", true);
}
});
try {
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
terminalHiddenRendererStore.markSoftHidden("images");
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
const delivered: string[] = [];
const later = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
});
try {
terminalHiddenRendererStore.markSoftHidden("other");
assert.deepEqual(delivered, []);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
} finally {
later();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a reserved leaving slot counts toward occupancy until it is released", () => {
const sessions = ["a", "b", "c", "d"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
terminalHiddenRendererStore.clearSoftHidden("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
terminalHiddenRendererStore.clearSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("victim");
let markerClearedBeforeSettle = false;
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.settleEviction("victim", false);
});
});
try {
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
assert.equal(markerClearedBeforeSettle, true);
assert.equal(completed, false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
} finally {
unsubscribe();
terminalHiddenRendererStore.clearSoftHidden("victim");
}
});
@@ -3,14 +3,85 @@ type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
const reservedLeavingSlots = new Map<string, number>();
let reservationToken = 0;
function reservedLeavingCount(): number {
let extra = 0;
for (const sessionId of reservedLeavingSlots.keys()) {
if (!softHiddenSessions.has(sessionId)) extra += 1;
}
return extra;
}
type EvictionWaiter = {
promise: Promise<boolean>;
resolve: (completed: boolean) => void;
settled: boolean;
};
const evictionWaiters = new Map<string, EvictionWaiter>();
function finishEviction(sessionId: string, completed: boolean): void {
const waiter = evictionWaiters.get(sessionId);
if (!waiter || waiter.settled) return;
waiter.settled = true;
evictionWaiters.delete(sessionId);
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
waiter.resolve(completed);
}
function emit(): void {
listeners.forEach((listener) => listener());
}
function listSoftHiddenOldestFirst(): string[] {
return [...softHiddenSessions.entries()]
.sort((left, right) => left[1] - right[1])
.map(([sessionId]) => sessionId);
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
/**
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
* yet in the marker set. Keep-count decisions use this so a second timer
* cannot take the slot a pending eviction already claimed.
*/
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
/**
* Claim one keep slot for a leaving tab before awaiting eviction.
* A session already in the marker set is not counted twice. A later claim
* for the same id replaces the token; only that token can release it.
*/
reserveLeavingSlot: (sessionId: string): number => {
const token = ++reservationToken;
if (sessionId && !softHiddenSessions.has(sessionId)) {
reservedLeavingSlots.set(sessionId, token);
}
return token;
},
releaseLeavingSlot: (sessionId: string, token: number) => {
if (reservedLeavingSlots.get(sessionId) === token) {
reservedLeavingSlots.delete(sessionId);
}
},
/**
* True when retaining this leaving tab would stay within keepCount.
* Its own reservation is not counted against the slots held by others.
*/
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
if (!(keepCount > 0)) return false;
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
return others < keepCount;
},
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
@@ -23,18 +94,12 @@ export const terminalHiddenRendererStore = {
emit();
},
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
return listSoftHiddenOldestFirst()[0] ?? null;
},
requestEviction: (sessionId: string) => {
@@ -42,6 +107,44 @@ export const terminalHiddenRendererStore = {
emit();
},
/** True while this session's hibernate has been requested and not settled. */
hasPendingEviction: (sessionId: string): boolean => {
const waiter = evictionWaiters.get(sessionId);
return waiter != null && !waiter.settled;
},
/**
* Ask one soft-hidden session to upgrade. Resolves true only when
* settleEviction reports that hibernation finished. Clearing the marker
* during wake is not success: drain, snapshot, or alternate-screen failure
* can still restore that renderer. A synchronous refusal (marker still
* present, including inline images) resolves false and drops an unconsumed
* request so a later emit cannot upgrade it.
*/
evictSoftHidden: (sessionId: string): Promise<boolean> => {
const existing = evictionWaiters.get(sessionId);
if (existing) return existing.promise;
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
let resolvePromise!: (completed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
evictionWaiters.set(sessionId, waiter);
evictionRequestSessionId = sessionId;
emit();
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
finishEviction(sessionId, false);
}
return promise;
},
/** Record whether the in-flight eviction's hibernate actually completed. */
settleEviction: (sessionId: string, completed: boolean) => {
finishEviction(sessionId, completed);
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
+2 -2
View File
@@ -13,11 +13,11 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
'sftp',
'browser',
'scripts',
+8 -2
View File
@@ -1,3 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const pluginThemeRevision = usePluginThemeRevision();
const { resolvedTheme } = useSettingsChromeStore();
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,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 };
}
+88
View File
@@ -0,0 +1,88 @@
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();
};
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 };
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginCatalog } from './usePluginCatalog';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
let renders = 0;
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
assert.equal(state.readmeStatus, 'loading');
await act(async () => calls[0].request.reject(new Error('temporary failure')));
assert.equal(state.readmeStatus, 'error');
assert.match(state.readmeError!, /temporary failure/);
await act(async () => state.retryReadme());
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
await act(async () => calls[1].request.resolve(''));
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
assert.equal(state.readmeError, null);
await act(async () => calls[3].request.resolve('# Second'));
await act(async () => calls[2].request.reject(new Error('stale failure')));
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.unmount());
const unmountedRenders = renders;
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
await act(async () => renderer.unmount());
});
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
let active = true;
setListStatus('loading'); setListError(null);
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog(sourceId);
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt, sourceId]);
useEffect(() => {
let active = true;
setSourceBinding(null);
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
return () => { active = false; };
}, [selectedId, sourceId]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
error: installError?.id === selectedId ? installError.message : null,
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
await bridge.resetPluginSource(id, sourceBinding.id);
setSourceBinding(null);
}
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
},
};
}
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -196,6 +198,9 @@ export function usePluginContributions(
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
@@ -218,6 +223,9 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
@@ -239,9 +247,11 @@ export function usePluginContributions(
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppHandlers } from '../app/appHandlersBridge';
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
import { PluginHostActionResources } from './pluginHostActionResources';
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
type Review = {
prompt: VaultHostPrompt;
candidates: Host[];
candidateRevisions: Map<string, string>;
customGroups: string[];
host?: Host;
groups: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
scriptsRun: boolean;
claimed: boolean;
completionToken?: string;
};
export function usePluginHostActions() {
const [review, setReview] = useState<Review | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const current = useRef<Review | null>(null);
const resources = useRef(new PluginHostActionResources());
const responding = useRef(false);
const getVault = useCallback(() => {
const vault = getAppVaultRuntime();
const lock = getAppAppLockRuntime();
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
const actionState = vault.readCurrentHostActionState();
resources.current.observe(actionState.hosts);
return { ...vault, ...actionState };
}, []);
const assertCurrent = useCallback((entry: Review) => {
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
const vault = getVault();
const target = entry.prompt.vaultTarget;
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
throw new Error('Saved connection settings changed');
}
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
}, [getVault]);
const fail = useCallback(async (entry: Review) => {
if (current.current !== entry) return;
setError(true);
try {
await netcattyBridge.get()?.respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
});
} catch { /* A stopped runtime or closed window already invalidates it. */ }
}, []);
const execute = useCallback(async (entry: Review) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondPluginInteraction) return fail(entry);
setBusy(true);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const target = entry.prompt.vaultTarget;
let action: PluginHostAction;
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
} else throw new Error('Host action is invalid');
await executePluginHostAction(action, {
getVault, resources: resources.current,
assertCurrent: () => assertCurrent(entry),
authorizeCommit: async () => {
assertCurrent(entry);
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
entry.completionToken = receipt.completionToken;
entry.claimed = true;
assertCurrent(entry);
},
connect: (host) => {
const connect = getAppHandlers()?.handleConnectToHost;
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
return connect(host);
},
});
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
} catch { await fail(entry); }
}, [assertCurrent, fail, getVault]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.prompt.requestId !== event.requestId) return;
current.current = null; setReview(null); setBusy(false); responding.current = false;
return;
}
if (!event.request.kind.startsWith('vault.')) return;
const prompt = event as VaultHostPrompt;
try {
if (prompt.phase === 'commit') {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
void execute(entry);
return;
}
const vault = getVault();
const candidates = [...vault.hosts];
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
const entry: Review = {
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
managedSources: vault.managedSources,
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
setReview(null);
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
} else setReview(entry);
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
});
return () => {
const entry = current.current;
current.current = null;
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, fail, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
responding.current = true; setBusy(true); setError(false);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
const revision = entry.candidateRevisions.get(id);
if (!revision) throw new Error('Host was not part of the current selection');
resources.current.resolve(id, revision);
return resources.current.metadata(id);
}) : undefined;
await netcattyBridge.require().respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
...(selected === undefined ? {} : { resources: selected }),
...(draft === undefined ? {} : { draft }),
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
});
} catch { await fail(entry); }
finally {
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
}
}, [assertCurrent, fail, selectedIds]);
return { review, selectedIds, setSelectedIds, busy, error, respond };
}
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<UiPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt;
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
setPrompt(null);
try {
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
});
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
} else setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
}
pending.current = false; setBusy(false); setError(false);
});
void bridge.setPluginInteractionReady(true).catch(() => {});
return () => {
current.current = null;
unsubscribe();
void bridge.setPluginInteractionReady?.(false).catch(() => {});
};
}, []);
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
const active = current.current;
if (!active || pending.current) return;
pending.current = true; setBusy(true); setError(false);
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const selection = action === 'preview' && active.request.kind === 'terminal.selection.read'
? readPluginTerminalSelection(active.terminalSessionId ?? '', active.terminalRendererId ?? '') : undefined;
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(selection === undefined ? {} : { value: selection }),
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
current.current = null; setPrompt(null);
}
} catch {
if (current.current?.actionToken === active.actionToken) setError(true);
} finally {
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
}
}, [value]);
return { prompt, value, setValue, error, busy, respond };
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginManager } from './usePluginManager';
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let fail = false;
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
addEventListener() {}, removeEventListener() {}, netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
},
} });
context.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginManager>;
function Probe() { state = usePluginManager(); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, true);
fail = true;
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
assert.equal(state.statusKnown, false);
assert.match(state.error ?? '', /synthetic status read failed/);
fail = false; plugin.enabled = false;
await act(async () => { await state.refresh(); });
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, false);
} finally { await act(async () => { renderer.unmount(); }); }
});
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [statusKnown, setStatusKnown] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setStatusKnown(true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
} catch (cause) {
if (current === generation.current) {
setStatusKnown(false);
setError(cause instanceof Error ? cause.message : String(cause));
}
throw cause;
}
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
window.addEventListener('netcatty:plugin-packages-changed', update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
await bridge.uninstallPlugin(id);
if (clearSource && source) {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
};
}
@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginPermissionPrompt() {
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
const [busy, setBusy] = useState(false);
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
const prompt = prompts[0];
active.current = prompt ?? null;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
const unsubscribe = bridge.onPluginPermission((event) => {
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
setBusy(false);
});
void bridge.setPluginPermissionReady(true).catch(() => {});
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
}, []);
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
if (!prompt || busy || active.current !== prompt) return;
setBusy(true);
try {
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
finally { setBusy(false); }
};
return { prompt, busy, respond };
}
+25
View File
@@ -0,0 +1,25 @@
import { useCallback, useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginSources() {
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const values = await netcattyBridge.require().listPluginSources?.();
if (!values) throw new Error('Source management unavailable');
setSources(values);
}, []);
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
const mutate = async (operation: () => Promise<unknown>) => {
setBusy(true); setError(null);
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
catch (cause) { setError(String(cause)); return false; }
finally { setBusy(false); }
};
return { sources, error, busy,
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
};
}
@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react';
import type { Terminal } from '@xterm/xterm';
import { getTerminalSelectionForClipboard } from '../../components/terminal/normalizeTerminalSelection';
import { publishTerminalSelectionTarget, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
type SelectionState = {
sessionId: string | null;
bootEpoch: number;
term: Terminal | null;
visible: boolean;
isFocusedPane: boolean | undefined;
connected: boolean;
sensitive: boolean;
normalize: boolean;
label: string;
};
// No retained snapshots, PTY adapter, recorder, or AI context reader enters here.
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
const target = () => {
const state = readState();
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
return { sessionId: state.sessionId, rendererId: `${instanceId}:${state.bootEpoch}`, label: state.label.slice(0, 160) || 'Terminal' };
};
return {
target,
read() {
if (!target()) throw new Error('The selected terminal is unavailable');
const { term, normalize } = readState();
const range = term!.getSelectionPosition();
// Refuse a large range before asking xterm to materialize its text.
if (!range || Math.abs(range.end.y - range.start.y) * term!.cols + Math.abs(range.end.x - range.start.x) > 4096) {
throw new Error('Select a bounded terminal range first');
}
return getTerminalSelectionForClipboard(term!, normalize);
},
};
}
export function usePluginTerminalSelection(key: string, readState: () => SelectionState, lifecycle: string) {
const latest = useRef(readState);
latest.current = readState;
const instance = useRef<string | null>(null);
if (instance.current === null) instance.current = crypto.randomUUID();
useEffect(() => registerTerminalSelectionSource(key, createPluginTerminalSelectionSource(() => latest.current(), instance.current!)), [key]);
// The target getter reads current refs. Publication revokes old renderer/boot identities.
useEffect(() => { publishTerminalSelectionTarget(); }, [lifecycle]);
}
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { toast } from '../../components/ui/toast';
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
/** Required production view-context projection, not a plugin-facing API. */
export interface PluginSidebarHostActions {
getActiveTerminalSessionId(): string | null;
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
}
export interface PluginTerminalSidebarPage {
tool: `plugin-view:${string}`;
viewId: string;
pluginId: string;
pluginName: string;
version: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
}
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
}
export function usePluginTerminalSidebar(host: {
layouts: ReadonlyMap<string, SidePanelLayout>;
getSession(): string | null;
isTerminalTab(tabId: string): boolean;
select(tool: `plugin-view:${string}`): void;
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
}) {
const { locale, t } = useI18n();
const activeTabId = useActiveTabId();
// Follow workspace focus without subscribing the whole sidebar to cwd/title
// or forwarding the host's live session DTO to a plugin.
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
const hostRef = useRef(host); hostRef.current = host;
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
const tabId = activeTabStore.getActiveTabId();
const current = hostRef.current.getSession();
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
toast.info(t('plugins.sidebar.selectTerminal'));
return;
}
hostRef.current.select(page.tool);
};
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
}, [pages, t]);
useEffect(() => {
if (contributions.loading) return;
const allowed = new Set(pages.map((page) => page.tool));
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
}
}, [contributions.loading, host, pages]);
return { pages, contributions, tabId: activeTabId, sessionId };
}
@@ -0,0 +1,77 @@
import { useEffect, useSyncExternalStore } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
export function usePluginThemeRevision() {
return useSyncExternalStore(
pluginThemeRegistry.subscribe,
pluginThemeRegistry.getRevision,
pluginThemeRegistry.getRevision,
);
}
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
let runtimeUsers = 0;
let disconnectRuntime: (() => void) | undefined;
function connectRuntime() {
const bridge = netcattyBridge.get();
let active = true,
request = 0;
const refresh = async () => {
const current = ++request;
try {
const catalog = await bridge?.getPluginThemes?.();
if (active && current === request && catalog)
pluginThemeRegistry.setCatalog(catalog);
} catch {
/* Keep the last host-validated selection; never apply unvalidated data. */
}
};
void refresh();
const dispose = bridge?.onPluginThemesChanged?.(() => {
pluginThemeRegistry.invalidate();
void refresh();
});
const forced = window.matchMedia?.("(forced-colors: active)"),
contrast = window.matchMedia?.("(prefers-contrast: more)");
const update = () =>
pluginThemeRegistry.setHighContrast(
Boolean(forced?.matches || contrast?.matches),
);
update();
forced?.addEventListener?.("change", update);
contrast?.addEventListener?.("change", update);
return () => {
active = false;
request++;
dispose?.();
forced?.removeEventListener?.("change", update);
contrast?.removeEventListener?.("change", update);
};
}
export function usePluginThemeRuntime() {
const revision = usePluginThemeRevision();
useEffect(() => {
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
return () => {
if (--runtimeUsers === 0) {
disconnectRuntime?.();
disconnectRuntime = undefined;
}
};
}, []);
return revision;
}
export async function applyPluginThemeSelection(
mode: "light" | "dark",
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
) {
if (choice === null && !pluginThemeRegistry.isActive(mode))
return pluginThemeRegistry.getCatalog();
const bridge = netcattyBridge.require();
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
const next = await bridge.applyPluginTheme({
mode,
choice,
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
});
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
}
+52
View File
@@ -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 };
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
export function usePluginVaultPages({ locale, section, select }: {
locale: string;
section: string;
select(section: 'hosts' | `plugin-vault:${string}`): void;
}) {
const active = useActiveTabId() === 'vault';
const sectionRef = useRef(section);
const focusFrame = useRef<number | null>(null);
sectionRef.current = section;
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
const contributions = usePluginContributions({ locale, context: CONTEXT });
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const selected = resolvePluginVaultPage(pages, section);
const fallback = useCallback(() => {
select('hosts');
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
focusFrame.current = requestAnimationFrame(() => {
focusFrame.current = null;
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
}
});
}, [select]);
useEffect(() => {
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
}, [contributions.loading, fallback, section, selected]);
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
activeTabStore.setActiveTabId('vault');
select(page.key);
};
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
}, [pages, select]);
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
}
+13 -1
View File
@@ -26,6 +26,7 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -82,7 +83,7 @@ export function usePluginViewLifecycle({
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) ?? null,
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
const activeView = resolvePluginViewSnapshotSelection({
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
+21
View File
@@ -1,4 +1,5 @@
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { flushSync } from 'react-dom';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import {
@@ -915,6 +916,25 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Agent callers bind scope immediately, so they need committed insertion,
// not just the ID allocated by the ordinary batched UI append operation.
const appendHostToWorkspaceConfirmed = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
let sessionId: string | null = null;
flushSync(() => {
sessionId = appendHostToWorkspace(workspaceId, host, direction);
});
if (!sessionId) return null;
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const inserted = committedWorkspace
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
return inserted ? sessionId : null;
}, [appendHostToWorkspace]);
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
// but constructs a local-protocol session instead of an SSH one.
const appendLocalTerminalToWorkspace = useCallback((
@@ -1453,6 +1473,7 @@ export const useSessionState = ({
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { collectSessionIds } from '../../domain/workspace';
import { activeTabStore } from './activeTabStore';
import { useSessionState } from './useSessionState';
test('workspace host opening acknowledges committed insertion and rejects queued closure', async (t) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
const globals = {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
CustomEvent: dom.window.CustomEvent,
Event: dom.window.Event,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
const root = createRoot(document.getElementById('root')!);
t.after(async () => {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
activeTabStore.setActiveTabId('vault');
});
let state: ReturnType<typeof useSessionState>;
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(Probe))));
const host = createLocalShellHost({ id: 'local-inheriting' });
await act(async () => state.createWorkspaceWithHosts('Target', [host]));
const workspaceId = state!.workspaces[0].id;
const resolvedHost = resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/tmp/project' },
});
let ordinaryId: string | null = null;
await act(async () => {
ordinaryId = state.appendHostToWorkspace(workspaceId, resolvedHost);
});
assert.ok(ordinaryId);
assert.ok(state!.sessions.some(session => session.id === ordinaryId));
assert.ok(collectSessionIds(state!.workspaces[0].root).includes(ordinaryId));
let insertedId: string | null = null;
await act(async () => {
insertedId = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.ok(insertedId, 'success is returned after both state updates commit');
const session = state.sessions.find(s => s.id === insertedId);
assert.equal(session?.localShell, '/bin/zsh');
assert.deepEqual(session?.localShellArgs, ['-l']);
assert.equal(session?.localStartDir, '/tmp/project');
assert.equal(state.sessions.filter(s => s.id === insertedId).length, 1);
assert.equal(collectSessionIds(state.workspaces[0].root).filter(id => id === insertedId).length, 1);
});
await act(async () => {
// The rendered ref still contains the workspace; its close updater is queued.
state.closeWorkspace(workspaceId);
const result = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.equal(result, null, 'must not acknowledge an allocated but uninserted ID');
});
assert.equal(state!.workspaces.length, 0);
assert.equal(state!.sessions.length, 0, 'no orphan session survives the queued close');
assert.equal(activeTabStore.getActiveTabId(), 'vault');
await act(async () => {
assert.equal(state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost), null);
});
});
test('agent workspace host opening uses the common launch and history handler', () => {
const source = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /const sessionId = handleConnectToHost\(host, true, hidden, target\.workspaceId\)/);
assert.match(source, /connectToHost: workspaceId\s*\? \(candidate: Host\) => appendHostToWorkspaceConfirmed\(workspaceId, candidate\)\s*: connectToHost/);
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -217,6 +218,7 @@ import {
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const pluginThemeRevision = usePluginThemeRuntime();
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -1,8 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import React, { startTransition, useState } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
@@ -130,3 +135,231 @@ test('closing the final pane removes only that terminal side panel state', async
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
let renders = 0;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
renders += 1;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 1, 'open tabs and layout share one commit');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
const layout = state!.sidePanelLayouts.get('terminal-a')!;
assert.equal(
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
'notes',
);
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
assert.deepEqual(
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
['notes', 'ai'],
);
assert.equal(
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
'notes',
);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
assert.notEqual(focusedExisting, beforeFocus);
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
const settled = state!.sidePanelLayouts;
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 0);
assert.equal(state!.sidePanelLayouts, settled);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('a pending side-panel write survives an urgent render on another tab', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let bump: ((value: number) => void) | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
const [, setTick] = useState(0);
bump = setTick;
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const tools = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
bump?.(1);
});
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
});
});
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.deepEqual(tools('terminal-b'), ['sftp']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
const body = layerSource.slice(start, end);
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
});
@@ -13,55 +13,215 @@ import {
type SidePanelTool,
} from '../../domain/sidePanelLayout';
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
if (previous.get(key) === value) return previous;
const next = new Map(previous);
next.set(key, value);
return next;
}
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
if (!previous.has(key)) return previous;
const next = new Map(previous);
next.delete(key);
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
): Map<string, SidePanelLayout> | null {
let changed = false;
const next = new Map(layouts);
for (const tabId of layouts.keys()) {
if (!openTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
for (const [tabId, tool] of openTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
return changed ? next : null;
}
export function useTerminalSidePanelLayoutState() {
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = sidePanelLayouts;
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
renderedLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
// public application boundary into the per-tab tree without disturbing the
// other panes in an existing layout.
// other panes in an existing layout. Skip the setState when the click path
// already stored this selection: scheduling an identical update still
// renders again after a paired openTabs write.
useEffect(() => {
const base = sidePanelLayoutsRef.current;
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
if (!reconciled) return;
sidePanelLayoutsRef.current = reconciled;
setSidePanelLayouts((previous) => {
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
const next = previous === base
? reconciled
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
if (!next) {
sidePanelLayoutsRef.current = previous;
return previous;
}
for (const [tabId, tool] of sidePanelOpenTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
if (changed) sidePanelLayoutsRef.current = next;
return changed ? next : previous;
sidePanelLayoutsRef.current = next;
return next;
});
}, [sidePanelOpenTabs]);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
const focusedTool = getFocusedSidePanelPane(layout).tool;
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
const commitMap = useCallback((
tabId: string,
layout: SidePanelLayout,
openTab: SidePanelTool | null,
) => {
const layoutBase = sidePanelLayoutsRef.current;
const openTabBase = sidePanelOpenTabsRef.current;
const layouts = openTab === null
? deleteMapEntry(layoutBase, tabId)
: replaceMapEntry(layoutBase, tabId, layout);
const openTabs = openTab === null
? deleteMapEntry(openTabBase, tabId)
: replaceMapEntry(openTabBase, tabId, openTab);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
setSidePanelLayouts((previous) => {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
return next;
});
}, []);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
}, [commitMap]);
// Click path writes the focused tool and the layout together. The effect
// above still reconciles external openTabs writes, and skips when
// selectSidePanelTool returns the layout this call just stored.
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
const current = sidePanelLayoutsRef.current.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
commitLayout(tabId, updated);
}, [commitLayout]);
const focusPane = useCallback((tabId: string, paneId: string) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
@@ -97,25 +257,23 @@ export function useTerminalSidePanelLayoutState() {
return false;
}
const layouts = new Map(sidePanelLayoutsRef.current);
const openTabs = new Map(sidePanelOpenTabsRef.current);
layouts.delete(tabId);
openTabs.delete(tabId);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
commitMap(tabId, layout, null);
return true;
}, [commitLayout]);
}, [commitLayout, commitMap]);
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
if (nextLayout === layout) return;
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
const base = sidePanelLayoutsRef.current;
const layouts = replaceMapEntry(base, tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts(layouts);
setSidePanelLayouts((previous) => {
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
sidePanelLayoutsRef.current = next;
return next;
});
}, []);
return {
@@ -129,5 +287,6 @@ export function useTerminalSidePanelLayoutState() {
splitPane,
closePane,
resizeSplit,
selectSidePanelToolForTab,
};
}
+1 -1
View File
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
stopTunnel: current.stopTunnel,
stopRuleTunnels: current.stopRuleTunnels,
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
@@ -25,7 +25,7 @@ test("close-path connection log updates await the indexedDB persist task", () =>
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
@@ -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);
}
}
});
+121 -30
View File
@@ -62,7 +62,8 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
acknowledgePendingConnectionLogWrites,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
@@ -70,6 +71,7 @@ import {
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
type PendingConnectionLogWrite,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
@@ -429,6 +431,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogWriteGenerationRef = useRef(0);
const connectionLogPendingRef = useRef(new Map<string, PendingConnectionLogWrite>());
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
@@ -518,17 +522,23 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainReplay: ConnectionLogTerminalDataMap = {};
const writeMain = (value: ConnectionLog[]) => {
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, value);
if (written) mainReplay = buildTerminalDataMapFromLogs(value);
return written;
};
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
mainPersisted = writeMain(prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
mainPersisted = writeMain(logs);
}
}
let sidePersisted = true;
let sidePersisted = false;
if (mainPersisted || !options?.pruneMainBlob) {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
@@ -545,11 +555,16 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
connectionLogsRef.current,
mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
freshLogs,
connectionLogTerminalDataPersistedRef.current,
connectionLogPendingRef.current,
),
freshLogs,
)
: logs;
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, toWrite);
mainPersisted = writeMain(toWrite);
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
// IndexedDB rejected the replay after the pruned metadata write. Put the
// buffers back on the current main blob so a restart still has them,
@@ -560,12 +575,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
sidePersisted = writeMain(fallbackLogs);
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
while (!sidePersisted && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
sidePersisted = writeMain(fallbackLogs);
}
}
@@ -578,7 +593,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return mainPersisted && sidePersisted;
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
@@ -589,28 +604,81 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const changedIds = new Set<string>();
const generation = ++connectionLogWriteGenerationRef.current;
for (const log of next) {
nextIds.add(log.id);
const before = prevById.get(log.id);
if (!before || before !== log) changedIds.add(log.id);
if (!before || before !== log) {
const previous = connectionLogPendingRef.current.get(log.id);
const changes: Partial<ConnectionLog> = { ...previous?.changes };
const fieldGenerations = { ...previous?.fieldGenerations };
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
if (!before || before[field] !== log[field]) {
Object.assign(changes, { [field]: log[field] });
fieldGenerations[field] = generation;
}
}
connectionLogPendingRef.current.set(log.id, {
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
});
}
}
for (const log of prev) {
if (!nextIds.has(log.id)) {
connectionLogPendingRef.current.set(log.id, { generation, log: null });
}
}
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
connectionLogsRef.current = next;
setConnectionLogs(next);
// The task may run after another window writes. Rebase onto the logs that
// exist then. Restore only ids this recipe added or replaced.
return enqueueTerminalDataTask(() => persistConnectionLogState(
rebaseQueuedConnectionLogs(
next,
removedIds,
changedIds,
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
// The shared lock covers the read/rebase as well as both storage writes.
// Include uncommitted generations from failed or newer queued captures.
await absorbRemoteTerminalDataIfNeeded();
const pending = new Map(connectionLogPendingRef.current);
const removedIds = new Set<string>();
const changedIds = new Set<string>();
for (const [id, mutation] of pending) {
if (mutation.log === null) removedIds.add(id);
else changedIds.add(id);
}
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const memory = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
),
{ ...options, removedIds, changedIds },
));
}, [enqueueTerminalDataTask, persistConnectionLogState]);
storedBefore ?? connectionLogsRef.current,
connectionLogTerminalDataPersistedRef.current,
pending,
);
const persisted = await persistConnectionLogState(
rebaseQueuedConnectionLogs(
memory,
removedIds,
changedIds,
memory,
storedBefore,
),
{ ...options, removedIds, changedIds },
);
// A replay/quota failure must not retain already-committed add or
// metadata ownership: retrying it could undo a peer bookmark/deletion.
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
connectionLogPendingRef.current, pending, persisted,
);
if (persisted.metadataPersisted || persisted.replayPersisted) {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (stored) {
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
stored,
connectionLogTerminalDataRef.current,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}
}
return persisted.metadataPersisted && persisted.replayPersisted;
}));
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
@@ -618,7 +686,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
return mergeConnectionLogsWithPendingMutations(
prev,
storedLogs,
terminalDataMap,
connectionLogPendingRef.current,
);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
@@ -703,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}, [waitForPendingVaultWrites]);
// Host-owned action guards need the synchronous mutation snapshot, including
// edits that React has not published yet. This accessor never crosses RPC.
const readCurrentHostActionState = useCallback(() => ({
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
managedSources: managedSourcesRef.current,
}), []);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
@@ -1175,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1249,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
// Optional plugin draft guard: runtime/source/revoke/lock authority must
// still hold after encryption, immediately before the existing atomic
// persistence boundary. Ordinary imports retain their existing behavior.
if (assertAuthorized) await assertAuthorized();
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
@@ -1497,6 +1582,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
@@ -2077,7 +2163,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
// A queued peer event may predate our successful commit. Read the
// current blob, then preserve only still-owned local generations.
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (!next) return;
const merged = applyConnectionLogsFromStorage(
connectionLogsRef.current,
next,
@@ -2123,7 +2212,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
@@ -2133,14 +2222,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsFromStorage(
connectionLogsRef.current,
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
map,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}));
})));
};
const handleLocalStorageAdapterChanged = (event: Event) => {
@@ -2442,6 +2532,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
+11
View File
@@ -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
+2
View File
@@ -411,6 +411,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
modelCatalogWarning={modelCatalogWarning}
onRefreshModelCatalog={handleModelCatalogRetry}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
+3
View File
@@ -1090,6 +1090,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
target.agentEnv,
target.agentCommand,
target.codexRuntime,
// Manual refresh (retry banner / picker button) must bypass the
// main-side LKG-first policy and go live.
options.force === true,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
Loaded 100 of 728 files, more files were not shown because too many files have changed in this diff. Show more