Compare commits

...
Author SHA1 Message Date
bincxz 8cb18d5430 perf(sync): coalesce pending indicator payload collection 2026-10-18 12:43:55 +08:00
陈大猫 df5c033dfa fix(ai): preserve Catty prompt cache and cache usage (#3714)
* fix(ai): preserve Catty prompt cache and provider usage

* fix(ai): gate explicit caching on the official Anthropic endpoint

* fix(ai): resolve provider defaults before enabling prompt caching
2026-10-18 12:20:18 +08:00
陈大猫 b5fee9632e feat(monitor): enrich and virtualize Vault monitoring views (#3713)
* feat(monitor): enrich and virtualize vault monitoring views

* fix(monitor): preserve outer gutter around sticky terminal actions

* fix(monitor): mask scrolled content behind action column corners

* feat(monitor): customize columns and clarify unavailable hosts

* fix(monitor): collect GPU data without delaying primary samples
2026-10-18 04:19:55 +08:00
陈大猫 fb69f4a48d fix(browser): integrate browser controls and show downloads immediately (#3712)
* fix(browser): unify embedded controls and show download progress immediately

* fix(browser): preserve input focus when background navigation finishes
2026-10-18 02:20:18 +08:00
陈大猫 1d8cba97ae refactor(scripts): remove stacked library view (#3711) 2026-10-18 00:30:29 +08:00
陈大猫 88caf82e83 fix(monitor): isolate app coordination and restore regression checks (#3710)
* fix(monitor): isolate runtime updates from app coordination

* test: restore transfer fault injection and native SSH expectations

* test(sftp): inject parent changes after either preparation path
2026-10-17 23:50:37 +08:00
陈大猫 e3c45097c2 fix(ssh): isolate native FIDO prompts and preserve shared identities (#3709)
* fix(ssh): isolate native FIDO prompts and preserve shared identities

* fix(ssh): launch MSYS askpass safely from paths containing spaces

* fix(ssh): normalize Windows agent bind paths for MSYS OpenSSH

* fix(ssh): keep managed agent sockets below the MSYS path limit

* fix(ssh): detach Windows device notifications from helper process exit

* fix(ssh): support per-session agents for Windows MSYS clients

* fix(ssh): quote native agent paths for OpenSSH config parsing
2026-10-17 22:45:59 +08:00
陈大猫 46de2023bd feat(sync): clear local backup history with confirmation (#3708)
Add explicit confirmation, on-disk cleanup and failure retry for local vault backup history, while preserving active and interrupted recovery snapshots.
2026-10-17 22:43:37 +08:00
陈大猫andnetcatty-bot 48f1a1cecf feat(ssh): restore FIDO2 security key support (#2823)
* feat(ssh): restore FIDO2 security key support (#2308)

Consolidate the restored feature and its review fixes before rebasing onto current main. Original PR head: 5d0a0c6c9a550364d3ec5c2331395cedfc9ea7ae.

* fix(ssh): verify and repair FIDO authentication with simulated hardware

Preserve complete OpenSSH SK signature payloads, classify combined PIN/presence prompts correctly, expand IdentityFile tokens, retain certificate selectors, and route shared-agent signing prompts to their originating window. Dismiss prompts when notification helpers exit. Add a software SK provider and opt-in real OpenSSH integration coverage for both algorithms, certificates, SFTP, forwarding, and concurrent windows.

* refactor(ui): refine security key forms and authentication motion

Use shared Select and Switch controls, concise localized copy and compact token-based dialogs. Add scoped enter/exit, field transition and presence animations with reduced-motion support. Preserve raw prompt details and visible PIN errors.

* fix(ssh): align security key prompts with hardware presence and verify Docker logins

* fix(ui): keep security key dialog above its remounted overlay

* fix: address Codex review on PR #2823

* feat(ssh): guide security key recovery and explicit authentication fallback

* test(ssh): exercise security key authentication on native Windows

* test(ssh): verify Windows fixture signatures and explicit fallback

* test(ssh): configure Windows OpenSSH toolchain and document coverage

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-17 21:53:44 +08:00
陈大猫 2bb52ce6ee ui(host): move connection test action into panel header (#3706) 2026-10-17 20:30:43 +08:00
陈大猫 c2c4b6ec77 Refine monitoring layout and shared host-picker styling (#3705)
* refine(monitor): compact panel controls into the Vault header

* refine(monitor): simplify navigation and match Vault visuals

* refine(vault): remove active host selection outlines

* refine(monitor): keep default cursor until dragging

* fix(monitor): label network directions for screen readers
2026-10-17 19:46:35 +08:00
陈大猫 3f35872638 Refine monitoring layout, disk details and refresh controls (#3704)
* Polish monitoring panels and preserve host icons

* Simplify monitoring layout and show every disk partition

* Simplify list rows and add resizable monitoring overlays

* feat(monitor): add modal drawers, sampled hover values and faster refresh

* feat(monitor): reset run history and support saved drag ordering

* perf(monitor): stream history deltas and keep fast samples lightweight

* fix(monitor): separate chart histories across connection identities

* refine(monitor): show disk capacity on hover and use inline sidebars

* fix(monitor): omit unchanged stats from polling deltas

* test(monitor): freeze time in full-history delta checks

* perf(monitor): bound chart paths and reuse unchanged histories
2026-10-17 18:59:38 +08:00
陈大猫 bace30578c feat: add synced custom host monitoring panels (#3703)
* feat: add synced custom host monitoring panels

* fix: preserve shared monitoring demand and complete terminal stats

* fix: transfer queued monitoring prompts when a window closes

* fix: keep reused hosts sampling while authentication is queued

* docs: record monitoring acceptance evidence

* fix: transfer pending monitoring auth after panel stop

* fix: release monitor owners in direct SSH runtime

* fix: keep worker monitoring sender compatibility

* fix: include monitoring configuration in automatic sync

* fix: disambiguate independent synced monitoring panel names

* test: assert existing panel order after cloud merge

* test: model browser locks and await monitoring state on Node 22
2026-10-17 15:05:30 +08:00
陈大猫 8aeb6ade6f fix(sftp): report transfer connection setup failures (#3702) 2026-10-17 12:32:11 +08:00
陈大猫andCodex b6bff4df6b feat(browser): browse host services from terminal side panels (#3701)
* Codex worktree snapshot: startup-cleanup

Co-authored-by: Codex

* fix(browser): close malformed proxy connections without a reply

* feat(browser): embed host browsing in terminal side panels

* style(browser): blend compact browser chrome into terminal panels

* feat(browser): open terminal links internally with a system browser preference

* fix(browser): retain the first terminal link during panel mount replay

* fix(browser): release contexts when their split pane closes

* style(browser): remove the downloads panel shadow

* fix(browser): ignore late downloads after panel closure

* fix(browser): mount browser content on terminal auto-open

* fix(browser): preserve empty-vault recovery with bookmarks

* fix(browser): expose actions for supported terminal types

* fix(browser): retain open requests before the panel mounts

* fix(browser): align vault actions with supported hosts

* fix(browser): share download controls across host contexts

* fix(browser): keep vault browser requests on visible sessions

* fix(browser): release contexts after workspace detach

* fix(browser): enforce WebRTC routing on page contents

* fix(browser): skip unsupported sessions in open requests

* style(browser): encode glyphs in ASCII-safe source
2026-10-17 02:26:36 +08:00
Netcatty-Bot 2cfe2a05ea fix(sync-ui): clarify disconnect and preserve failure retry
Make provider disconnect visible and explain the local credentials removed and the vault, backup history, shared sync password and cloud copies retained. Report failed disconnects and preserve a retry path instead of implying success.

Validation: focused sync and dialog checks, isolated Electron cancel/failure/retry/reload against WebDAV, two independent final full-diff reviews, exact-head Codex review and current GitHub checks; the main integration tree also passed focused checks.

Related to #3688. Device handover cleanup remains outside this patch and the issue remains open.
2026-10-16 10:45:01 +08:00
Netcatty-Bot 7ed7250f34 fix(vault): clean deleted jump host references across deletion flows
Deleting saved hosts now removes their jump references from surviving hosts and group defaults in all ordinary deletion flows. Host-only bulk deletion uses the existing vault mutation boundary, and explicit empty chains retain their original group-override meaning.

Validation: 282 focused checks, lint/build, two independent full-diff reviews, latest-head Codex review, current GitHub checks, and actual isolated Electron deletion/restart/native SSH reconnection.

Fixes #3691
2026-10-16 10:38:15 +08:00
陈大猫 2ce0164fa9 Merge pull request #3642 from GOUKI9999/feat/unified-model-catalog-ux
feat(ai): unify SDK model catalog loading and sources
2026-10-15 22:48:27 +08:00
bincxz 2e55b9348f test(ai): keep Copilot fixtures in the managed temp directory 2026-10-15 22:27:11 +08:00
bincxz 1e79dadbe5 fix(ai): let the Copilot SDK own its native runtime directly 2026-10-15 22:13:44 +08:00
bincxz 52e15f8df7 fix(ai): match the official Copilot npm loader package 2026-10-15 21:51:06 +08:00
bincxz 7497e64ab4 fix(ai): preserve custom Copilot JavaScript launchers 2026-10-15 21:48:23 +08:00
bincxz 9888f07cb2 fix(ai): keep Copilot wrapper independent of parent module scope 2026-10-15 21:33:40 +08:00
bincxz 293ca1098e fix(ai): preserve authoritative Grok reasoning capabilities 2026-10-15 21:17:37 +08:00
陈大猫 35a47fe95a Merge pull request #3633 from yuyang2230/feat/cloud-sync-union-merge-and-ai-sessions
feat(sync): non-destructive union merge + AI sessions in cloud sync
2026-10-15 21:09:49 +08:00
bincxz 82add1bda6 test(ai): align Windows Copilot checks with native passthrough 2026-10-15 21:03:04 +08:00
bincxz 239804bf62 fix(ai): retain native Copilot lifecycle and clean undated catalog labels 2026-10-15 21:01:19 +08:00
bincxz 460d9bb7c6 fix(ai): allocate Copilot wrapper through managed temp files 2026-10-15 20:08:44 +08:00
bincxz b7996c58e3 fix(sync): preserve durable chats and stop turns before replacement 2026-10-15 20:05:43 +08:00
bincxz ab50ad93b7 fix(ai): hide irrelevant catalog warnings for configured Codex models 2026-10-15 19:53:28 +08:00
bincxz c86116ac08 fix(ai): keep configured Codex models clear of catalog loading 2026-10-15 19:49:07 +08:00
bincxz 5ac8c8bf1f fix(sync): honor chat deletions and exact backup restoration 2026-10-15 19:33:12 +08:00
bincxz b99de934a3 fix(ai): align catalog source with retained and configured models 2026-10-15 19:30:54 +08:00
bincxz 8e88b99131 fix(sync): preserve chat turn order and recovery boundaries 2026-10-15 19:09:02 +08:00
bincxz d7122683d2 fix(ai): show loading only for pending model catalogs 2026-10-15 19:02:26 +08:00
陈大猫 2c67e3c3ce Merge pull request #2987 from Zeral-Zhang/feature/toggle-window-opacity
feat: toggle window transparency
2026-10-15 19:01:18 +08:00
bincxz 7e9e9b53eb Merge remote-tracking branch 'origin/main' into codex/takeover-pr-3642 2026-10-15 18:58:37 +08:00
陈大猫 e8bbc9a5c9 Merge pull request #3624 from GOUKI9999/fix/model-catalog-blockers
fix(ai): unblock model catalogs for copilot and mimo agents
2026-10-15 18:56:20 +08:00
bincxz 3de34cae5d fix: leave IME processing keys out of transparency toggles 2026-10-15 18:43:56 +08:00
bincxz 7851ddb01e fix(sync): complete chat entity labels and key typing 2026-10-15 18:42:53 +08:00
bincxz de517a6237 fix(sync): honor chat consent during download and initial enable 2026-10-15 18:41:17 +08:00
bincxz aca81953fe fix: keep detached opacity shortcut configuration current 2026-10-15 18:31:34 +08:00
bincxz 9a484ba3cb fix(ai): forward Copilot profile environment to model discovery 2026-10-15 18:30:27 +08:00
bincxz dfc7961b68 fix(sync): complete portable chat and preservation flows 2026-10-15 18:26:17 +08:00
陈大猫 41d2a2d4af Merge pull request #3646 from GOUKI9999/fix/job-startup-diagnostics
feat(ai): instrument job startup pipeline for #3443 diagnostics
2026-10-15 18:23:11 +08:00
bincxz af9ee5d697 fix(ai): preserve completed Grok catalog probes on deadline 2026-10-15 18:17:49 +08:00
bincxz 2aae278d53 fix: isolate settings delivery failures per window 2026-10-15 18:16:28 +08:00
bincxz d9632ab985 Merge latest model catalog fixes from PR 3624 2026-10-15 18:14:00 +08:00
bincxz 82ee429eab fix(ai): distinguish raw probe arrivals from outbound writes 2026-10-15 18:10:01 +08:00
bincxz b70abfd70d test: allow shared opacity in detached settings guard 2026-10-15 18:05:45 +08:00
bincxz 1fe313b229 Merge remote-tracking branch 'origin/main' into codex/pr-2987-takeover 2026-10-15 18:04:15 +08:00
bincxz fd559eb095 fix: keep transparency toggles synchronized and remember opacity 2026-10-15 18:03:47 +08:00
bincxz 3f2b877ba6 Merge commit 'refs/pr-takeover/3633/main' into codex/takeover-pr-3633 2026-10-15 18:02:57 +08:00
bincxz 707a8749c9 fix(ai): keep Copilot selection consistent across discovery states 2026-10-15 18:02:42 +08:00
bincxz 2d5f90ac2e Merge remote-tracking branch 'origin/main' into codex/pr-3624-fix 2026-10-15 18:01:46 +08:00
Fyhax1kiandfyhax1ki d0e024625c feat: add clear-all command history action (#3632)
Add the Global history clear-all action with confirmation and five-language copy. Clear local shell history, autocomplete and compose recall while preserving drafts, keeping peer windows consistent on clears, and allowing new commands afterwards.

Verified with 116 focused checks, two clean independent final reviews, real Electron multi-window SSH-fixture interaction and restart, current-head Codex review, and both required CI jobs.

Co-authored-by: fyhax1ki <fyhax1ki@gmail.com>
2026-10-15 18:00:43 +08:00
bincxz 9a8f9bb0c6 Merge current main into transparency toggle branch 2026-10-15 17:53:14 +08:00
bincxz b512133209 fix(ai): collect worker startup diagnostics in the main log 2026-10-15 17:52:56 +08:00
bincxz 83ee53ba96 Merge remote-tracking branch 'origin/main' into codex/pr-3646-startup-diagnostics
# Conflicts:
#	electron/bridges/ai/ptyExec.cjs
2026-10-15 17:49:45 +08:00
bincxz fa07263628 fix(ai): persist first current-only model catalog 2026-10-15 17:46:59 +08:00
bincxz 08c0ac4f59 fix(ai): preserve model selection on degraded Copilot catalogs 2026-10-15 17:44:32 +08:00
bincxz d298275378 Merge remote-tracking branch 'origin/main' into codex/pr-3624-fix 2026-10-15 17:38:02 +08:00
陈大猫 cdf9b334d5 fix(ai): keep undo branches in the viewing scope (#3686) 2026-10-15 17:22:56 +08:00
陈大猫 158f204baa fix(terminal): withdraw unreleased archive command shortcut
Withdraw the unreleased archive-command shortcut from #3682. Real first-party SFTP locate,auto-run multiline snippets and paste controls showed old filenames preparing commands in a changed directory. The attempted fragment-based tracking required growing state guards,so defer this implementation rather than expand the feature.

The final diff only removes the original seven files/248 added lines.141 related checks,lint,build and real full-app normal-input,SFTP,snippet,existing-link and draft controls passed. Current-head remote review and both required CI jobs passed. Existing AI repairs are untouched.

Related to #3269 and #3682. The feature request stays open. No release.
2026-10-15 16:58:18 +08:00
陈大猫 1c34384651 fix(ai): preserve shell hooks when stopping agent commands
Stopping paced AI input could leave user shell hooks disabled. Remove the unproven PS0 mutation while preserving the existing combined-output fix.

Independent validation:310 checks pass,7 skipped;24 real Bash/SSH normal,cancellation and combined-output controls. Current-head remote review,automated tests and all platform packages passed.

Related to #3683 and #3485. Original reporter acceptance remains pending. No release.
2026-10-15 16:25:25 +08:00
Netcatty-Bot 3e7d871a33 fix(ai): silence bash PS0 around agent PTY command echoes (#3683)
Neutralize bash PS0 around agent PTY wrapper reads, then restore. Skipped Codex P2 on __NCMCP_PS0 collision as edge-case thrash.

Fixes #3485
2026-10-15 15:47:14 +08:00
陈大猫 758966a79c fix(ai): send chat image attachments as data URLs (#3681)
* fix(ai): encode chat image attachments as data URLs

* test(ai): keep image bridge fixture type safe
2026-10-15 15:45:23 +08:00
陈大猫 f0458dd7cc feat(terminal): prepare tar and zip commands from archive links (#3682)
* feat(terminal): prepare tar and zip commands from archive links

* fix(terminal): expire archive links after another command
2026-10-15 15:44:47 +08:00
陈大猫 52797e9edd fix(port-forwarding): preserve exhaustion across duplicate failures (#3679)
* fix(port-forwarding): preserve exhaustion across duplicate failures

* fix(port-forwarding): reset exhausted state on active adoption

* fix(port-forwarding): confirm adoption before clearing exhaustion
2026-10-15 15:26:04 +08:00
陈大猫 cc001c9a4b fix: keep OpenCode plugin errors from stopping resumed chats (#3677)
* fix: keep OpenCode plugin errors from stopping resumed chats

* fix: preserve each OpenCode background warning
2026-10-15 15:04:36 +08:00
陈大猫 b81b27f817 fix(ai): preserve terminal replies between OSC contexts (#3678) 2026-10-15 15:03:21 +08:00
陈大猫 3a2bf6498d feat(vault): move selected hosts to a group (#3675)
Add a Move to group action for the selected hosts, reusing existing group moves and saving the selection once. Omit the hidden synthetic General destination.

Related to #3194; marquee selection and dragging a selection are outside this change.

65 focused checks, lint, build and real grid/list mouse/keyboard/cancel/reload/single-host drag checks pass. Current-head remote review and both CI jobs pass.
2026-10-15 14:52:16 +08:00
陈大猫 53a4bf29b0 fix(terminal): retain absent local shell display snapshots (#3674)
Preserve the original local shell name and icon when a saved host is edited and a terminal is copied.

Related to #3615 and #3616.

Validated with 70 focused checks, lint, production build and actual terminal copy flows. Current-head remote review and CI pass.
2026-10-15 14:44:11 +08:00
0ed481c397 feat(ai-chat): fork a conversation from a completed response (#3604)
* fix(#3600): automated AI fix

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix(ai): narrow chat forks to independent transcript copies

* test(ai): keep fork render check correctly typed

* fix(ai): remove forked output arguments and support keyboard access

* fix(ai): guard signed fork reasoning and scan eligibility once

* fix(ai): remove bare source output references from fork text

* fix(ai): sanitize fork attachment previews without changing data

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 14:38:53 +08:00
陈大猫 e85c1fea30 feat(terminal): add a switchable Unix path highlight rule (#3672)
* feat(terminal): highlight Unix paths with existing keyword rules

* fix(terminal): keep Unicode and quoted paths together

* fix(terminal): exclude relative path suffix highlights

* fix(terminal): preserve punctuation inside path text
2026-10-15 14:37:16 +08:00
Netcatty-Bot 6c2bf43ee5 fix(vault): let local shell (CMD) sessions be saved into groups (#3616)
Related to #3615 (stays open: hiding/restoring running sessions is separate work).

Merged by judgment after ~49 Codex findings; remaining P2 (Copy Tab name/icon snapshot when host shell is edited after launch) is a cosmetic edge case for a follow-up.
2026-10-15 14:13:46 +08:00
陈大猫 abdf254702 fix(sftp): report downloads interrupted by a closed channel
Report a closed SFTP channel instead of leaving a download waiting for a source-file open callback that cannot arrive. Keep existing upload and reconnect policies.

Related to #3406. The original connection-loss cause and reporter environment remain unconfirmed; keep the issue open.

Validation: 42 download checks and 7 lifecycle/cancellation checks; real 1 GiB normal download, pause/continue, and interrupted retry with full checksums; exact-head Codex review clean; current test and four platform package jobs successful.
2026-10-15 13:45:06 +08:00
陈大猫 35b83d4655 fix(ai): preserve original chat when undo reaches history limit (#3670) 2026-10-15 13:09:10 +08:00
Netcatty-Bot b6b575b045 fix: avoid unused Bash cleanup input in live zsh sessions (#3576)
Keep the initial live-shell probe short and omit unused Bash history-cleanup input after a confirmed zsh reply. Preserve the existing bounded task wrapper, Bash cleanup, history and cancellation behavior.

Related to #3575. This repairs a confirmed controlled failure; the reporter-specific startup contrast remains open for confirmation and release delivery.

Validated through real configured zsh and Bash terminals, quoted/multiline and 3,000-character commands, the complete local test suite, exact-head Codex review and current automated checks.
2026-10-15 13:00:10 +08:00
陈大猫 3d5cd6b8ec fix: match host reuse to actual protocol settings (#3669)
Match host reuse to the actual Telnet login, ET hop ports and serial/local session settings. Preserve already-scoped reconnecting tabs when other sessions remain live, and reject Telnet proxy settings consistently before reusing a connection.

Follow-up to #3614. Related to #3613; no claim of a released fix or full PR3616 inherited-default coverage.

Validated with focused checks, real Electron/TCP Telnet connections, build/lint, exact-head Codex review and current automated test/package checks.
2026-10-15 12:55:56 +08:00
71373d1ad1 feat(ai-chat): add non-destructive undo last turn with safe-boundary branching (#3602)
* fix(#3599): automated AI fix

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* refactor(ai-chat): limit undo to conversation and draft restoration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:50:14 +08:00
Netcatty-Bot b4e056dd4a fix(sftp): support writable GVFS destinations without losing file protection
Handle unsupported copyFile operations on writable GVFS/FUSE destinations using the existing exclusive bounded copy path. New files accept the mount's creation permissions; replacing an existing file still requires equivalent protection, and failures retain recoverable data.

Only explicitly optional permission/timestamp updates may tolerate permission refusal. Content writes, close errors, required protection, and rollback preservation remain strict. Current-head review and required checks were verified before merging.

Related to #3611. Keep the report open pending delivery and actual environment confirmation.
2026-10-15 12:19:58 +08:00
d5c7d3fbf4 fix(ai): make host_open reuse-first and keep reconnecting sessions in get_environment (#3614)
* fix(#3613): automated AI fix

* fix(ai): preserve owners when reusing active host sessions

* fix(ai): match opening connection identity before host tab reuse

* fix(ai): preserve serial settings when matching reusable tabs

* fix: capture restored plugin connection before host reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:17:34 +08:00
陈大猫 1ab07ffee4 fix(ssh): preserve password fallback after unavailable agent
Allow ssh2 to continue to another authentication method when a system SSH agent is unavailable, for both target and jump-host connections. Keep the authentication deadline and fatal-error handling intact.

Validated with real SSH servers and Unix agent sockets through normal terminal startup, including forwarding, successful password fallback, real authentication failures, and timeouts. 118 focused checks and lint passed. Current-head review and required CI were checked before merging.

Related to #3640. Keep the report open for the reporter's environment and delivery validation.
2026-10-15 12:13:43 +08:00
GOUKI9999 e8ad5b3d29 Merge remote-tracking branch 'fork/fix/model-catalog-blockers' into pr3642-fix 2026-10-15 10:54:31 +08:00
GOUKI9999andClaude Sonnet 5.5 b7953a6b26 fix(ai): require advertised thinking levels for suffixed current ids; fingerprint ANTHROPIC_BASE_URL
- Last-known-good serving keeps a decorated current id only when the base row
  advertises that level (same rule as the renderer's modelPresetsContainId).
- ANTHROPIC_BASE_URL joins the main-process and renderer catalog cache keys so
  a different Claude endpoint never serves another endpoint's persisted list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-15 10:54:16 +08:00
GOUKI9999andClaude Sonnet 5.5 0a01af5144 fix(ai): use raw probe byte counter in events; log worker-side job misses
- probe-output-arrived and probe-reply-parsed report the cumulative raw
  byte count, consistent with the startup timeout record.
- A poll/stop that the worker answers with 'Background job not found' while
  the route still exists now emits job-stop-miss (worker-job-missing). The
  route itself is left alone; this PR stays diagnostics-only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-15 10:53:22 +08:00
GOUKI9999 848f201cf9 Merge remote-tracking branch 'fork/fix/model-catalog-blockers' into pr3642-fix 2026-10-14 15:58:44 +08:00
GOUKI9999andClaude Sonnet 5.5 5c901c6b04 fix(ai): keep effort-decorated current id from last-known-good when its base row exists
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:58:31 +08:00
GOUKI9999andClaude Sonnet 5.5 de3410f6e1 fix(ai): name the first probe output checkpoint honestly
The first decoded chunk while probing may be only the echoed probe command,
so probe-reply-arrived overstated what was seen. Rename it (and the timeout
stat) to probe-output-arrived; probe-reply-parsed remains the proof of reply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:57:44 +08:00
GOUKI9999andClaude Sonnet 5.5 a76971e2c5 fix(ai): keep Grok effort-decorated current id, ASCII-only sources; merge #3624 fixes
- last-known-good serving keeps grok-4.6/high when the grok-4.6 row exists.
- Replace non-ASCII dashes/middle dot in files that were ASCII-only.
- Merge #3624 (suffixed Copilot row, retry warning on retained catalog).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:13:39 +08:00
GOUKI9999andClaude Sonnet 5.5 84bf1cbfff fix(ai): keep suffixed Copilot selection selectable and keep retry warning on retained catalog
- appendRetainedStoredModelPreset appends the exact suffixed id when a
  base-only row exists that does not cover the suffix.
- createSdkRuntimeModelCache carries a degraded refresh's warning onto the
  retained cached catalog so the banner and Retry stay visible.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:10:39 +08:00
GOUKI9999andClaude Sonnet 5.5 d4d07d48af fix(ai): report route-removed once and count cmd-write bytes as UTF-8
- deleteWorkerRoute only emits when it actually removes the matching route,
  so a concurrent cancel plus terminal cleanup yields one record.
- cmd-write.bytes uses Buffer.byteLength instead of UTF-16 length.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:05:50 +08:00
GOUKI9999andClaude Sonnet 5.5 1ff40e859c fix(ai): return no runtime presets for an empty catalog and sync GROK_CONFIG_DIR
- an empty catalog now yields no runtime presets even when the user has custom
  composer models, so the built-in fallback list stays visible
- add GROK_CONFIG_DIR to the renderer cache env hints (the sync test caught
  the drift after the main-process key was added)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 00:01:42 +08:00
GOUKI9999 edf07fc74e Merge branch 'pr3624-fix' into pr3642-fix 2026-10-13 23:59:44 +08:00
GOUKI9999andClaude Sonnet 5.5 6a1bea2bce fix(ai): count raw probe bytes, log lookup misses in the main handlers, keep diagnostics ASCII
- count probe bytes from the raw chunk before decoding, so an incomplete UTF-8
  prefix and multi-byte text are reported in bytes
- log job-stop-miss from the main-process poll/stop handlers too: once the
  worker route is gone the dispatcher falls through to them
- replace the circled numeral in the new diagnostics module

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:59:32 +08:00
GOUKI9999andClaude Sonnet 5.5 7a1f415a91 fix(ai): fingerprint the Grok login directory and keep the persistence module ASCII-only
- add GROK_CONFIG_DIR to the model catalog cache env keys so two grok login
  profiles sharing HOME and the binary do not share a catalog
- replace the em dash added in the current-only refresh comment

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:57:01 +08:00
GOUKI9999andClaude Sonnet 5.5 745f6a3120 test(ai): expect the Copilot headless wrapper on Windows only
The wrapper is no longer used on POSIX, so the two driver tests that asserted
it on every platform now assert the direct CLI path there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:49:21 +08:00
GOUKI9999andClaude Sonnet 5.5 19faa554a4 fix(ai): address review feedback on the unified model catalog UX
- generate the Copilot headless wrapper in the managed temp directory, create it
  exclusively and only reuse an existing file whose content matches; a tampered
  file is not handed to the SDK
- use the headless wrapper on Windows only; POSIX passes the resolved CLI straight
  to the SDK so forceStop() reaches the real process
- keep the renderer model-cache env hints in sync with the main-process keys
  (CODEBUDDY_* and XAI_API_KEY) and guard it with a test
- forward source/fetchedAt from list-models so a last-known-good catalog is not
  labelled live
- do not retain a saved Claude alias on top of an empty catalog, which would turn
  a one-row list into the authoritative one and hide the built-in presets
- ASCII-only comments in the two new modules

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:40:33 +08:00
GOUKI9999 c0a66f1e88 Merge branch 'pr3624-fix' into pr3642-fix
# Conflicts:
#	components/AIChatSidePanelHelpers.test.tsx
#	electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
2026-10-13 23:32:10 +08:00
GOUKI9999andClaude Sonnet 5.5 6a79400112 fix(ai): address review feedback on the model catalog PR
- keep an already-qualified {provider, model} default in getOpenCodeDefaultModelIds
  instead of prefixing the provider twice
- resolve npm's copilot.ps1 wrapper to the JS entry on Windows like .cmd/.bat
- when serving a last-known-good catalog, remap a persisted current id that has
  no row to the first visible row for every backend (null still means default),
  so the picker and the send preflight agree
- reconcile the catalog warning banner from the result of every refresh, so a
  panel that joined another panel's in-flight load also shows it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:31:08 +08:00
GOUKI9999andClaude Sonnet 5.5 13bcf47282 fix(ai): address review feedback on the job startup diagnostics
- keep jobDiag.stats.probeBytes in sync so the startup-timeout event reports
  the bytes of a partial probe reply
- route every worker route-map deletion through one helper that emits
  route-removed (poll/stop completion, stale check, chat cancel, terminal close)
- label a poll/stop miss as route-missing, scope-mismatch or
  worker-unavailable instead of always route-missing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 21:32:28 +08:00
GOUKI9999 e5abae559c feat(ai): instrument job startup pipeline for #3443 diagnostics
Add read-only [Netcatty JobDiag] timeline emission covering the four
checkpoints requested in #3443: probe write/reply/parse (2), command
write + start marker (3), startup timeout with byte stats, interrupt
delivery with flow-control snapshots, and the MCP background-job route
lifecycle (4). Every payload is keyed by sessionId + jobId (jobId was
previously absent from all logs) and carries monotonic at/sinceStartMs
timestamps.

interrupt-callback-invoked reports finishedAtCallback so the recovery
ordering finding from the #3443 investigation is directly observable;
interrupt-callback-skipped fires when recovery is skipped (no callback
wired or callback threw). Checkpoint 1 (flow-control samples) remains
covered by the existing [Netcatty Ctrl+C] / [Netcatty Terminal Perf]
emitters; session snapshots here reuse the same helpers.

Enabled via NETCATTY_JOB_DIAG=1 or the existing NETCATTY_TERMINAL_DEBUG=1.
Zero behavior change: instrumentation calls only, jobId/sessionId/
diagnosticsSession are optional startPtyJob options defaulting to null.
2026-10-13 11:44:59 +08:00
GOUKI9999 d653180c3c fix(ai): union the grok ACP and CLI catalogs so a session subset cannot shrink the picker
- ACP initialize _meta.modelState reflects the negotiated agent session and
  has been observed to advertise only a subset of the account's models
  (e.g. grok-4.6/4.5 without the 4.7 family), while `grok models` lists the
  full account set; the old short-circuit (non-empty ACP catalog wins) made
  the live picker silently drop models one source knows about
- run both probes together and merge by id: ACP entries win for shared ids
  because they carry live vendor _meta (reasoning efforts, description);
  fields an ACP entry lacks are filled from the CLI row
- tests: mergeGrokCatalogModels unit cases (subset union, dedupe, field
  fill, malformed sides) and registry-level union/no-dupe coverage
2026-10-13 10:43:08 +08:00
GOUKI9999 0006634f6b fix(ai): show build-time presets while the opencode catalog loads
opencode was the only SDK agent whose picker rendered nothing until its
live catalog pull resolved: the #3584 suppression replaced presets with
just the user's custom ids while runtimeModelLoading was set. Align it
with every other backend — presets stay selectable during loading, the
live catalog replaces them on arrival, and #3585's retry/warmer
mechanics are untouched. Adds a regression guard against reintroducing
the opencode gating.
2026-10-12 18:12:34 +08:00
GOUKI9999 729ffcf162 fix(ai): capture dash-bullet grok model entries in the CLI catalog parse
Ground truth from grok 1.0.50 (`grok --no-auto-update models`): the default
model is listed with a "*" bullet but every other entry uses "-", so the
previous ^\*+-only regex silently dropped 3 of 4 live models and the
"live" picker shrank below the build-time presets. Accept any bullet
marker (*/-/*) so separator dashes still cannot match. Adds the captured
raw-output fixture plus an ACP initialize fixture (grok-4.7 / 4.7-build-fast /
4.6 / 4.5) asserting the registry ACP path keeps every availableModels entry.
2026-10-12 18:12:34 +08:00
GOUKI9999 e642c4953f feat(ai): unify model catalog UX across SDK agents
Implements the unified model catalog UX on top of the last-known-good
persistence layer (#3624):

- R1 source provenance end-to-end: list-models now returns
  source: 'live' | 'last-known-good' plus fetchedAt (ISO) on live
  successes and last-known-good serve-outs; the pre-flight cache replays
  the original provenance on hits. The renderer cache persists
  source/fetchedAt alongside the catalog, and the build-time preset
  fallback derives 'presets'.
- R2 loading state for all backends: drop the opencode-only gate on
  modelCatalogLoading so every SDK backend shows the composer loading
  state while its live fetch is in flight (opencode behavior unchanged).
- R3 unified status chip: ComposerModelPicker renders a muted footer chip
  for every backend - live / cached with relative last-success time /
  built-in default list - replaced by the spinner row while loading.
  Keys added to en/es/zh-CN/zh-TW (ru keeps its pre-existing ai.opencode
  gap).
- R4 copilot headless wrapper: generated per-resolve wrapper (baked with
  the platform exe the npm loader would spawn) is handed to
  RuntimeConnection.forStdio, spawning with windowsHide:true so no
  console window flashes in Electron. Falls back to the loader when the
  exe cannot be resolved (window may flash - documented degradation).
2026-10-12 16:53:00 +08:00
GOUKI9999 cb737ba0c9 fix(ai): keep refreshed current rows readable, share the mimo catalog deadline, and isolate grok catalog env
- modelCatalogLastKnown: a current-only refresh to an id absent from the
  persisted list now synthesizes a minimal row (label = the id) instead of
  leaving the persisted current id dangling, so a later live failure serves
  a list whose current row the picker and the send preflight agree on
- mimoDriver: sdkStreamHandlers hands an absolute catalog deadline down
  (outer budget minus 1s headroom); mimo serve readiness and the providers()
  query draw from that one pool instead of re-arming independent 25s caps,
  so the pair can no longer outrun the 30s outer model-list budget
- sdkStreamHandlers: add XAI_API_KEY to SDK_MODEL_CACHE_ENV_KEYS so two
  grok profiles sharing one binary but not credentials never share a
  catalog (MODEL_CACHE_ENV_HINTS does not exist in this tree)
2026-10-12 15:01:19 +08:00
GOUKI9999 c6a1f11fc9 fix(ai): preserve current-only selections, isolate codebuddy catalog env, and robusten copilot auto fallback
- last-known-good store: a current-only catalog (zero visible models,
  valid currentModelId) now refreshes the persisted current id via
  refreshLastKnownCatalogCurrentId instead of being skipped, so the
  user's selection survives a later live failure without clobbering
  the persisted models list
- SDK_MODEL_CACHE_ENV_KEYS: add CODEBUDDY_API_KEY, CODEBUDDY_AUTH_TOKEN
  and CODEBUDDY_INTERNET_ENVIRONMENT so two codebuddy profiles sharing a
  binary but not credentials no longer share one catalog cache key
- isCopilotAutoOnlyFallback: detect the copilot auto row among the
  renderer-appended rows (composer custom ids, retained selections)
  instead of requiring exact single-entry shape, keeping saved explicit
  selections valid when the picker list mixes extra rows
2026-10-12 12:29:36 +08:00
陈奇 5ed58345aa fix(ai): preserve unspecified effort on explicit Claude models 2026-10-12 04:06:53 +00:00
GOUKI9999 5f29d91499 fix(ai): keep effort suffixes on retained copilot preset rows
appendRetainedStoredModelPreset reduced a saved selection to its base id,
so a suffixed saved copilot model (e.g. claude-sonnet-4.5/high) produced
a row that no longer matched the stored id: the picker fell back to auto
while the send preflight passed the saved id. Append the exact stored
spelling when no build-time preset defines the base id.
2026-10-12 11:53:15 +08:00
GOUKI9999 5d9c8f29de fix(ai): qualify provider/model defaults before the model-only branch
getOpenCodeDefaultModelIds checked the bare value.model string before the
provider-aware shapes, so {provider: 'xiaomi', model: 'chosen'} returned
the unqualified 'chosen' and mimo's visibility check could never match
its fully qualified model ids. Mirror the singular resolver's branch
order.
2026-10-12 11:53:15 +08:00
陈奇 355d53abe3 fix(ai): retain degraded catalog warnings through cache copies 2026-10-12 03:40:23 +00:00
GOUKI9999 1ee0f8441c fix(ai): keep preserved saved models visible in the picker and cache fallback warnings
A stored selection preserved through the claude-alias or copilot
auto-only degraded fallback had no preset row, so the picker could show
no selected row while the send preflight passed the hidden value; append
a preset row (reusing the build-time preset when it defines the id).

Last-known-good responses were cached without their warning, so another
panel or a remount within the renderer TTL presented degraded models as
live and hid the Retry action. The warning now travels with the cached
catalog and re-syncs the banner on cached hits.
2026-10-12 11:36:19 +08:00
GOUKI9999 c396fc78ef fix(ai): resolve the mimo default across a multi-key default map
getOpenCodeDefaultModelId returns only the first default entry, so a
catalog declaring the hidden auto channel before the xiaomi provider made
the filtered catalog fall back to the first model instead of the CLI's
configured Xiaomi default. Enumerate every advertised default and select
the first one that survives filtering.
2026-10-12 11:36:19 +08:00
GOUKI9999 c48514381e fix(ai): preserve a saved Copilot model during degraded discovery
The auto-only COPILOT_MODEL_PRESETS fallback is nonempty, so a live
listModels failure invalidated a saved explicit Copilot model selection:
shouldUseStoredAgentModel only preserved unknown stored ids when the
preset list was empty, and picker plus send path silently switched to
auto. A saved id keeps resolving while the presets are exactly the
auto-only fallback.
2026-10-12 11:15:53 +08:00
GOUKI9999 5ae5965556 fix(ai): keep the current model inside the persisted catalog cap
writeLastKnownCatalogEntry sliced models to the 200-entry cap without
checking whether the current model survived the slice, so a huge live
catalog could persist a currentModelId with no picker row and the
last-known-good serve would let the picker and the send preflight
disagree again. Retain the current model in the capped set.
2026-10-12 11:15:52 +08:00
GOUKI9999 0050e895df Merge fork/fix/model-catalog-blockers: adopt upstream fixes and keep complementary catalog hardening
Adopts the PR author's commits for the copilot shutdown race, the mimo
filtered-channel default, and the claude alias fallback presets. Keeps the
complementary pieces: last-known-good mimo read-back normalization and
renderer-side preservation of stored claude aliases against live full-id
catalogs.
2026-10-12 11:07:15 +08:00
GOUKI9999 060fbbb2d1 fix(ai): keep stored claude alias selections when the catalog lists full ids
Preset lists before the 2026-10 claude catalog refresh persisted
sonnet/haiku alias ids. A live catalog of full ids made
shouldAdoptSdkCurrentModel treat such a stored selection as stale and
silently overwrite it, even though the claude CLI still resolves the
alias. Stored aliases now keep resolving (picker and send path) and are
never replaced by the catalog's current model; other backends and
non-alias ids are unchanged.
2026-10-12 11:00:49 +08:00
GOUKI9999 8c64280d87 fix(ai): cancel the losing copilot shutdown timer and force-stop on rejection
stopCopilotClient's 3s grace timer stayed alive whenever stop() won the
race, leaking one handle per turn/catalog load. Clear it in a finally. A
stop() rejection also no longer counts as settled, so the forceStop()
fallback still runs when the graceful handshake fails.
2026-10-12 11:00:49 +08:00
GOUKI9999 422bf120a9 fix(ai): keep the mimo current id off the filtered auto channel
A live default on the filtered mimo auto channel surfaced a currentModelId
the picker could not render or check while sends still went through the
channel. Replace it with the vendor's documented default when the remaining
models offer it, else the first remaining model, else null; ids outside the
filtered channel pass through untouched. Apply the same normalization when
serving a persisted last-known-good catalog captured before filtering.
2026-10-12 11:00:35 +08:00
陈奇 555e911887 fix(ai): preserve saved Claude fallback aliases 2026-10-12 02:45:39 +00:00
陈奇 74b7ce6c8c fix(ai): align MiMo catalog default with visible models 2026-10-12 02:45:39 +00:00
陈奇 3b78e097e6 fix(ai): force Copilot shutdown after stop rejection 2026-10-12 02:45:39 +00:00
xu 6052cbbd1f feat(sync): non-destructive union merge + AI sessions in cloud sync
Cloud sync uploaded the whole encrypted vault as a single payload. Two
problems follow from that, both of which we hit in production:

1. A one-sided deletion propagated to every device. Restoring an older
   local backup is indistinguishable from "the user deleted these 25
   hosts", so the next sync happily overwrote the cloud replica and
   every other machine with the older, smaller vault.

2. AI chat sessions were not part of the sync payload at all, so they
   never travelled between machines and never appeared in any backup.

This change adds an opt-in "keep all" merge and makes sessions syncable.

## Non-destructive merge

`mergeSyncPayloads` gains `{ preserveDeletions }`. When set, an entity
missing on one side is carried forward from the side that still has it;
only entities absent from base *and* both sides are removed. It is
exposed as the new `smartMergePreserve` strategy ("Smart merge (never
delete)") and threaded through every merge call site:

- `providerSyncMethods.syncToProviderImpl`
- `syncAllStorageMethods`
- `useAutoSync`

Default behaviour (`smartMerge`) is unchanged, so existing users see no
behaviour change until they opt in.

## AI sessions in the payload

`SyncPayload.aiSessions` is new and opt-in via the
"Sync AI chat history" switch in Settings → Cloud Sync. Sessions are
unioned by id, and messages are merged **append-only**: a message is
identified by id and the merged transcript is the union of all three
sides ordered by timestamp. Two devices that appended different turns to
the same conversation therefore keep both turns, instead of one device
silently dropping the other's work.

Applying a downloaded payload unions with the local transcript rather
than replacing it, so an older cloud snapshot can never delete turns
that only exist locally.

Inline attachment blobs (`base64Data`) are stripped before upload —
image/PDF payloads would multiply the encrypted vault size and may carry
data the user never intended to leave the device. Attachment metadata
(filename, media type, line count) is kept for context.

## Tests

- `domain/syncMerge.preserveDeletions.test.ts` — one-sided deletions
  (local and remote), both-sides deletion, additions, notes/snippets/
  port-forwarding/string entities, idempotence, summary reporting.
- `domain/syncMerge.aiSessions.test.ts` — session union, append-only
  message merge, timestamp ordering, metadata LWW, strategy-aware
  deletion, attachment stripping, idempotence.

Verified locally: 1939/1939 tests pass across the domain, convergentSync,
i18n, cloudSync and application sync suites; eslint clean on all touched
files.
2026-10-11 15:48:54 +08:00
GOUKI9999 ecd988ea4c fix(ai): resolve last-known catalog path via app.getPath and bound persisted entries
Review feedback on PR #3624:
- resolveLastKnownCatalogPath now reads userData through
  electronModule.app.getPath, matching the real bootstrap where
  electronModule is the require('electron') namespace (previously the
  persistence layer was a silent no-op in production)
- the last-known store now caps entries at LAST_KNOWN_MAX_ENTRIES (32,
  mirroring the in-memory TTL cache) and evicts the least recently
  updated entries, so churned fingerprinted cache keys cannot grow the
  store file without bound
- replace em dashes with ASCII punctuation per repo convention
- test stub now mirrors the real electronModule shape
2026-10-10 19:15:53 +08:00
GOUKI9999 258d72bdca fix(ai): restore copilot loader-first resolution and add copilot/mimo preset fallbacks
Revert d2b1d5fa2's default preference of the native copilot.exe: the
1.0.59-era native CLI bootstraps %LOCALAPPDATA%\copilot\pkg\ on first
start, starts slowly, and leaves a conhost child alive after app-side
timeout/abort (console window stays open; CLI server exited unexpectedly
with code 0). Loader-first (npm-loader.js) is the default again; the
native binary is opt-in via NETCATTY_COPILOT_PREFER_NATIVE=1.

Also:
- Add COPILOT_MODEL_PRESETS (auto) so a live listModels failure no
  longer leaves the Copilot picker empty.
- Sync MIMO_MODEL_PRESETS to the 6 verified live-catalog models
  (v2.6/v2.5 pro-ultraspeed/pro/flash); auto channel stays filtered
  driver-side.
- Reap the Copilot CLI child on abort/stop: stopCopilotClient() races
  graceful client.stop() with a 3s grace and falls back to the SDK's
  forceStop() (SIGKILL) so the spawned process does not linger.
2026-10-10 19:07:11 +08:00
GOUKI9999 157e5f03f0 Merge branch 'fix/model-catalog-blockers' into test/model-catalog-plus-presets 2026-10-10 18:37:56 +08:00
GOUKI9999 d2b1d5fa20 fix(ai): prefer the native copilot.exe over npm-loader.js in SDK resolution
npm-loader.js re-spawns the native binary via spawnSync WITHOUT
windowsHide, flashing a console window when the Copilot SDK runs inside
Electron's GUI main process on Windows. When the platform-native binary
(@github/copilot-<platform>-<arch>/copilot.exe) exists next to the
resolved package, hand it to RuntimeConnection.forStdio directly — the
SDK spawns it itself with windowsHide:true. Fall back to npm-loader.js
when the native package is absent (e.g. optional deps omitted).
2026-10-10 18:33:47 +08:00
GOUKI9999 cc6ba30891 Merge remote-tracking branch 'fork/fix/claude-preset-refresh' into test/model-catalog-plus-presets 2026-10-10 18:21:10 +08:00
GOUKI9999 2fefdf6f0c Merge branch 'fix/model-catalog-blockers' into test/model-catalog-plus-presets 2026-10-10 18:21:09 +08:00
GOUKI9999 01d6c4ceaf fix(ai): filter the mimo auto channel out of the live catalog 2026-10-10 18:20:04 +08:00
GOUKI9999 9b7d2a6a34 fix(ai): expand Claude fallback presets to the full live-generation list 2026-10-10 18:10:40 +08:00
GOUKI9999 d79a4de401 Merge fork/fix/claude-preset-refresh into test/model-catalog-plus-presets 2026-10-10 17:49:19 +08:00
GOUKI9999 9a17691c32 fix(ai): widen the mimo inner catalog setup budget to 25s
The catalog path boots a dedicated `mimo serve` and then queries providers();
the inner awaitMimoSetup race (and the serve readiness timeout) was 10s and
lost on the coldest boots even though the outer mimo budget is now 30s. Give
the catalog path a 25s inner budget (MIMO_CATALOG_SETUP_TIMEOUT_MS, passed as
both the spawn readiness timeout and the providers race). runMimoTurn keeps
the 10s default.
2026-10-10 17:11:21 +08:00
GOUKI9999 c22bf0ab9b feat(ai): fall back to last-known-good model catalogs on live fetch failure
Persist each successful live catalog ({userData}/model-catalog-last-known.json,
keyed by the same backend+binPath+runtime+envHash string as the in-memory TTL
cache) and serve it when a later live fetch fails, before degrading to curated
presets. Copilot has no presets at all, so this is its first real fallback.
Empty catalogs are never persisted; corrupt files and IO errors degrade
gracefully; per-entry models are capped at 200. The response keeps the
warning contract and adds source/fetchedAt markers for the UI.
2026-10-10 17:11:21 +08:00
GOUKI9999 494cd57e25 fix(ai): refresh Claude fallback presets to the 5.x generation
The curated CLAUDE_MODEL_PRESETS fallback still advertised the 4.6
generation while the live CLI catalog serves Opus/Sonnet/Haiku 5.5 and
Fable 5.1. Alias ids (`default`/`sonnet`/`haiku`) are unchanged so the
CLI still resolves them; only display names move: sonnet/haiku now map
to claude-sonnet-5-5 / claude-haiku-5-5 in the Claude CLI alias table
(verified against CLI 2.1.296), and `default` has no fixed alias target,
so it keeps the CLI's own neutral "Default (recommended)" label.
2026-10-10 16:39:45 +08:00
GOUKI9999 f0169ad632 fix(ai): resolve copilot .cmd shim before spawning SDK
RuntimeConnection.forStdio({ path }) makes the Copilot SDK spawn the
CLI through node WITHOUT shell:true. Windows npm installs expose
copilot as a .cmd shim, and Node >= 18.20 throws spawn EINVAL for
.cmd/.bat in milliseconds, so listCopilotModels()/runCopilotTurn()
could never start the runtime via the PATH-resolved shim.

Add resolveCopilotExecutableForSdk() to shellUtils (same per-CLI
resolver pattern as claude/codex/codebuddy): resolve the shim to the
package's real npm-loader.js entry via the npm layout shortcut, then
by parsing the shim, and pass .exe/.js entries through unchanged.
Apply it at both RuntimeConnection.forStdio call sites; unresolvable
shims keep the original path so behavior is unchanged.

Harness: PATH-resolved copilot.cmd -> npm-loader.js, catalog [auto]
returned in 3.0-3.6s (was: EINVAL in 1-2ms, empty catalog).
2026-10-10 15:51:21 +08:00
GOUKI9999 df74164d54 fix(ai): skip skill-path probe in mimo catalog and extend its list timeout
listMimoModels() spawned `mimo debug config` only to seed the server's
skills config, which the provider catalog never reads. That probe alone
costs 6-8s on a cold CLI, pushing the whole chain (probe + serve boot +
config.providers()) past the shared 10s model-list timeout, so the mimo
catalog always degraded to an empty list with a UI warning.

- drop the readMimoSkillPaths() call from listMimoModels(); the turn
  path (runMimoTurn) still reads skill paths unchanged
- add a per-backend catalog timeout override map; mimo gets 30s because
  it cold-boots a dedicated `mimo serve` process per catalog pull
  (unlike OpenCode's pooled server), while every other backend keeps
  the existing 10s (opencode: 10s + server start) budget

Harness: no debug-config spawn, catalog 7 models in 7.7-10.5s (was:
timeout error at 10s, empty catalog every time).
2026-10-10 15:51:07 +08:00
Zeral 565ab504f4 Merge branch 'main' into feature/toggle-window-opacity 2026-08-24 13:13:26 +08:00
Zeral Zhang 9958ee5097 feat: toggle window transparency 2026-08-14 20:11:57 +08:00
479 changed files with 39839 additions and 1836 deletions

No files matched your search

+44
View File
@@ -52,6 +52,9 @@ jobs:
- name: Test
run: npm test
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
- name: Test terminal background rendering
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
@@ -71,6 +74,47 @@ jobs:
- name: Build
run: npm run build
windows-fido:
name: windows-fido
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci --ignore-scripts
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
- name: Install user-mode OpenSSH and software authenticator toolchain
uses: msys2/setup-msys2@v2
with:
msystem: MSYS
update: true
install: gcc openssl-devel openssh
- name: Build disposable software authenticator
shell: msys2 {0}
run: |
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
cygpath -w /usr/bin >> "$GITHUB_PATH"
ssh -V
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
env:
NETCATTY_FIDO_WINDOWS: "1"
run: node --test scripts/fido-windows.live.test.cjs electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
windows-broadcast-ssh:
name: windows-broadcast-ssh
runs-on: windows-latest
+4
View File
@@ -1,3 +1,5 @@
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
import React, { useEffect } from 'react';
import { initializeFonts } from './application/state/fontStore';
@@ -47,7 +49,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
function App() {
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
<AppSideEffects />
<MonitorHostKeyDialog />
<AppShell />
</AppLocalStateProvider>
);
+67
View File
@@ -7,6 +7,7 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -398,3 +399,69 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
assert.equal(hosts[0].password, 'old-password');
assert.deepEqual(errors, ['Request not found']);
});
test('local host without explicit shell resolves the configured Settings shell', () => {
const connectedHosts: Host[] = [];
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
const discoveredShells = [
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
];
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session';
},
discoveredShells,
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
});
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
const connectedHosts: Host[] = [];
const localHost: Host = {
...baseHost,
id: 'local-2',
hostname: 'localhost',
protocol: 'local',
localShell: 'pwsh',
};
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session-2';
},
discoveredShells: [],
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'pwsh');
assert.equal(connectedHosts[0].localShellName, undefined);
});
@@ -699,6 +699,89 @@ test('connection log host snapshot includes custom host icon fields', () => {
);
});
test('toggleOpacity hotkey invokes toggleWindowOpacity', () => {
let called = false;
const noop = () => {};
executeHotkeyActionImpl(
() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWindowOpacity: () => { called = true; },
toggleWorkspaceViewMode: noop,
workspaces: [],
}),
'toggleOpacity',
{ key: 'u', code: 'KeyU', ctrlKey: true, shiftKey: true } as KeyboardEvent,
);
assert.equal(called, true);
});
for (const scenario of [
{ name: 'IME process key with its original physical code', key: 'Process', keyCode: 229, isComposing: false, expected: false },
{ name: 'key during composition', key: 'U', keyCode: 85, isComposing: true, expected: false },
{ name: 'ordinary shortcut', key: 'U', keyCode: 85, isComposing: false, expected: true },
]) {
test(`transparency shortcut respects ${scenario.name}`, () => {
const target = new FakeHTMLElement();
const actions: string[] = [];
let prevented = false;
const event = {
...scenario,
code: 'KeyU',
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => actions.push(action),
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), event);
assert.deepEqual(actions, scenario.expected ? ['toggleOpacity'] : []);
assert.equal(prevented, scenario.expected);
});
}
for (const scenario of [
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
+44 -6
View File
@@ -4,6 +4,7 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -204,7 +205,10 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleConnectToHostImpl) instead of
// 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -302,6 +306,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
// IME-filtered keys retain their physical code (e.g. Linux Unicode entry).
// Let composition handle them instead of treating them as opacity toggles.
if (binding.action === 'toggleOpacity' && (e.isComposing || e.keyCode === 229 || e.key === 'Process')) {
return;
}
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
@@ -491,6 +500,20 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
}
}
export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, response, false);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, '', true);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
@@ -777,7 +800,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -967,6 +990,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
setNavigateToSection('snippets');
}
break;
case 'toggleOpacity':
toggleWindowOpacity();
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
@@ -1104,11 +1130,15 @@ export function handleCreateLocalTerminalImpl(
}
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveEffectiveHost(host);
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
// Handle serial hosts separately
if (effectiveHost.protocol === 'serial') {
@@ -1138,7 +1168,10 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleCreateLocalTerminalImpl)
// instead of 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -1186,8 +1219,13 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
// Gates the protocol picker (legacy name kept for its existing wiring).
// Only prompt when Telnet is available but isn't the host's default protocol;
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
// serial hosts must always launch their own transport, never offer
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
// without an explicit protocol are legacy SSH entries.
const effective = resolveEffectiveHost(host);
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
const effectiveProtocol = effective.protocol ?? 'ssh';
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
}
}
+14 -3
View File
@@ -34,6 +34,17 @@ test('serial targets use the serial editor', () => {
);
});
test('local targets use the local shell editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({
mode: 'edit',
openedHost: host({ protocol: 'local' }),
requestId: 3,
}),
'local',
);
});
test('new and ssh targets use the standard editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
@@ -71,8 +82,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
assert.match(source, /\[&>\*\]:pointer-events-auto/);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
});
test('editor host panels share vault resize width persistence', () => {
@@ -81,7 +92,7 @@ test('editor host panels share vault resize width persistence', () => {
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
assert.match(source, /resizable:\s*true/);
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
});
test('editor stays mounted while another app surface is active', () => {
+17 -3
View File
@@ -5,6 +5,7 @@ import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useI18n } from '../i18n/I18nProvider';
import HostDetailsPanel from '../../components/HostDetailsPanel';
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
import { PortalContainerProvider } from '../../components/ui/portal-container';
import { resolveGroupDefaults } from '../../domain/groupConfig';
@@ -22,14 +23,16 @@ import type {
} from '../../types';
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
export function resolveWorkSurfaceHostEditorKind(
target: WorkSurfaceHostEditorTarget,
): WorkSurfaceHostEditorKind {
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
? 'serial'
: 'standard';
: target.mode === 'edit' && target.openedHost.protocol === 'local'
? 'local'
: 'standard';
}
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
@@ -160,7 +163,18 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
style={getAppHostEditorLayerStyle(surfaceVisible)}
>
<PortalContainerProvider container={portalContainer}>
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
<LocalShellHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
groups={groups}
onSave={onSave}
onCancel={onCancel}
layout="overlay"
className="pointer-events-auto"
{...hostPanelResizeProps}
/>
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
<SerialHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { createHostTerminalSession } from '../state/sessionFactories';
// Exercise the production application callback, including its async command
// resolution boundary, before the state hook snapshots the shell type.
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
assert.ok(callback);
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
for (const script of [false, true]) {
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
let session: ReturnType<typeof createHostTerminalSession> | undefined;
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
(value: unknown) => value,
() => script,
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
session = createHostTerminalSession('snippet-local', hosts[0]);
},
async () => 'echo hello',
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: 'cmd' },
resolveShellSetting: () => ({ command: 'cmd.exe' }),
}),
);
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
assert.equal(session?.protocol, 'local');
assert.equal(session?.localShell, 'cmd.exe');
assert.equal(session?.shellType, 'cmd');
});
}
+102 -24
View File
@@ -38,9 +38,11 @@ import {
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import { useI18n } from '../i18n/I18nProvider';
import { matchesKeyBinding } from '../../domain/models';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
import { upsertKnownHost } from '../../domain/knownHosts';
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
@@ -53,6 +55,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -83,6 +86,7 @@ import { toast } from '../../components/ui/toast';
import { VaultSection } from '../../components/VaultView';
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
import { classifyLocalShellType } from '../../lib/localShell';
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
@@ -91,7 +95,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -136,6 +140,8 @@ export function AppSideEffects() {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
@@ -149,6 +155,7 @@ export function AppSideEffects() {
showSftpTab,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
toggleWindowOpacity,
} = settings;
// Always publish terminal keyboard focus to the main process. When font zoom
@@ -269,22 +276,6 @@ export function AppSideEffects() {
renameSessionInline,
} = sessionState;
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
// AppSideEffects only needs host/session maps for glue handlers and effects.
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts);
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
},
[runSnippet],
);
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
const editorTabs = useEditorTabChromeList();
@@ -848,6 +839,48 @@ export function AppSideEffects() {
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []);
// FIDO2 PIN / touch prompts from main-process askpass / sk-helper
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptRequest) return;
const unsubscribe = bridge.onFidoPromptRequest((request) => {
console.log('[App] FIDO prompt request:', request);
setFidoPromptQueue((prev) => [...prev, {
...request,
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
}]);
});
return () => { unsubscribe?.(); };
}, []);
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
}, [t]);
const handleFidoPromptCancel = useCallback((requestId: string) => {
return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptTimeout) return;
const unsubscribe = bridge.onFidoPromptTimeout((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
toast.error(t('fido.prompt.timeout'));
});
return () => { unsubscribe?.(); };
}, [t]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptCancelled) return;
const unsubscribe = bridge.onFidoPromptCancelled((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
});
return () => { unsubscribe?.(); };
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -1093,6 +1126,7 @@ export function AppSideEffects() {
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWindowOpacity,
toggleWorkspaceViewMode,
workspaces: workspacesRefForHotkeys.current,
}), action, e);
@@ -1105,6 +1139,7 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
moveFocusInWorkspace,
toggleBroadcast,
toggleWindowOpacity,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
confirmIfBusyLocalTerminal,
@@ -1210,11 +1245,16 @@ export function AppSideEffects() {
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
const handleConfirmDeleteHost = useCallback(async () => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
try {
await deleteVaultHosts([deleteHostConfirm.hostId]);
setDeleteHostConfirm(null);
} catch {
toast.error(t('common.error'));
}
}, [deleteHostConfirm, deleteVaultHosts, t]);
const handleCancelDeleteHost = useCallback(() => {
setDeleteHostConfirm(null);
@@ -1366,8 +1406,32 @@ export function AppSideEffects() {
{ validProxyProfileIds: proxyProfileIdSet },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
// Local-shell vault hosts saved with "System default shell" carry no
// localShell; resolve the Settings → Terminal shell here at the shared
// effective-host boundary so every launch path (connect, tray/dock open,
// workspace creation/append, tunnels) uses the configured shell instead
// of silently falling back to the backend OS default shell.
return resolveHostDefaultLocalShell(effectiveHost, {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
},
[runSnippet, resolveEffectiveHost],
);
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
@@ -1398,14 +1462,17 @@ export function AppSideEffects() {
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
discoveredShells,
host,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1432,6 +1499,10 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1474,6 +1545,7 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -1941,6 +2013,8 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
handleRequestCloseEditorTabRef,
resolveEmptyVaultConflict,
@@ -1964,6 +2038,7 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
@@ -2012,6 +2087,8 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
resolveEmptyVaultConflict,
handleCancelDeleteHost,
@@ -2027,6 +2104,7 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
+16 -4
View File
@@ -13,6 +13,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { FidoPromptModal } from '../../components/FidoPromptModal';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
@@ -37,6 +38,7 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
@@ -260,18 +262,18 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -354,14 +356,16 @@ function AppViewInner({ domains }: AppViewProps) {
[proxyProfiles],
);
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
defaultLocalShell,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
@@ -746,6 +750,7 @@ function AppViewInner({ domains }: AppViewProps) {
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onClearCommandHistory={clearAllCommandHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -1033,6 +1038,13 @@ function AppViewInner({ domains }: AppViewProps) {
onSkip={handlePassphraseSkip}
/>
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
<FidoPromptModal
request={fidoPromptQueue?.[0] || null}
onSubmit={handleFidoPromptSubmit}
onCancel={handleFidoPromptCancel}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
+7
View File
@@ -0,0 +1,7 @@
import { useMonitorRuntime } from '../state/useMonitorRuntime';
/** Keep monitoring state and subscriptions outside the app coordinator. */
export function MonitorRuntimeHost() {
useMonitorRuntime();
return null;
}
+4
View File
@@ -4,6 +4,7 @@ import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
type Listener = () => void;
@@ -31,6 +32,7 @@ export type AppLocalUiSnapshot = {
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
fidoPromptQueue: readonly FidoPromptRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
@@ -49,6 +51,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
@@ -81,6 +84,7 @@ class AppLocalUiStore {
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.fidoPromptQueue === next.fidoPromptQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
+4
View File
@@ -58,12 +58,15 @@ export function DialogsHost() {
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleFidoPromptCancel: handlers.handleFidoPromptCancel,
handleFidoPromptSubmit: handlers.handleFidoPromptSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
@@ -83,6 +86,7 @@ export function DialogsHost() {
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
+1
View File
@@ -36,6 +36,7 @@ export function VaultHost() {
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
clearAllCommandHistory: actions?.clearAllCommandHistory,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useState } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
assert.ok(declaration, `${name} must exist`);
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
});
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
}
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let updateMonitor: (() => void) | undefined;
let runtimeRenders = 0;
let sideEffectRenders = 0;
let shellRenders = 0;
const useMonitorRuntime = () => {
const [, setVersion] = useState(0);
updateMonitor = () => setVersion((version) => version + 1);
runtimeRenders += 1;
};
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', {
useMonitorRuntime,
MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
AppSideEffects: () => { sideEffectRenders += 1; return null; },
MonitorHostKeyDialog: () => null,
AppShell: React.memo(() => { shellRenders += 1; return null; }),
});
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(App)); });
const initialRuntimeRenders = runtimeRenders;
const initialSideEffectRenders = sideEffectRenders;
const initialShellRenders = shellRenders;
for (let i = 0; i < 10; i += 1) {
await act(async () => updateMonitor!());
}
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
} finally {
await act(async () => renderer?.unmount());
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
@@ -72,6 +72,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
} = vault;
useLayoutEffect(() => {
@@ -144,12 +145,14 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
clearAllCommandHistory,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
+1 -1
View File
@@ -542,7 +542,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}
return;
}
console.log('[App] Keyboard-interactive request received:', request);
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
@@ -1,9 +1,12 @@
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
installBrowserLockFixture();
const NOW = 1_700_000_000_000;
const localStorageValues = new Map<string, string>();
+2
View File
@@ -14,6 +14,7 @@ import {
type ConvergentMigrationProviderInput,
} from '../domain/convergentSync';
import { isProviderReadyForSync } from '../domain/sync';
import { isPreserveDeletionsStrategy } from '../domain/syncStrategy';
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
import { applyProtectedSyncPayload } from './localVaultBackups';
@@ -113,6 +114,7 @@ export async function prepareConvergentSyncMigration(
providers: inputs,
deviceId: manager.getState().deviceId,
now,
preserveDeletions: isPreserveDeletionsStrategy(manager.getState().syncStrategy),
});
if (plan.state) {
for (const seed of legacyBaselineSeeds) {
+78
View File
@@ -0,0 +1,78 @@
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { startPortForward } from '../infrastructure/services/portForwardingService';
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
import { isValidHslToken } from './state/settingsStateDefaults';
interface OpenHostBrowserOptions {
host: Host;
sessionId?: string;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
}
const pendingOpens = new Map<string, Promise<void>>();
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
const style = getComputedStyle(element);
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
const value = style.getPropertyValue(`--${token}`).trim();
return isValidHslToken(value) ? [[token, value]] : [];
}));
}
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
}
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
await bridge.openHostBrowserUrl({ browserId, url });
}
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
const existing = pendingOpens.get(options.browserId);
if (existing) return existing;
const opening = (async () => {
const bridge = netcattyBridge.get();
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
throw new Error('The host browser is unavailable in this build');
}
const local = host.protocol === 'local';
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
id: `host-browser-${crypto.randomUUID()}`,
label: `Browser: ${host.label || host.hostname}`,
type: 'dynamic',
hostId: host.id,
localPort: 0,
bindAddress: '127.0.0.1',
status: 'inactive',
createdAt: Date.now(),
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
knownHosts, { dedicatedTransport: true, prepareOnly: true });
if (!result.success || (!local && !result.connectionOptions)) {
throw new Error(result.error || 'Could not prepare the browser connection');
}
await bridge.openHostBrowser({
browserId: options.browserId,
embedded: true,
local,
hostId: host.id,
label: host.label || host.hostname,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
themeTokens: currentBrowserThemeTokens(),
connectionOptions: result.connectionOptions,
});
})();
pendingOpens.set(options.browserId, opening);
void opening.finally(() => {
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
}).catch(() => {});
return opening;
}
+23
View File
@@ -0,0 +1,23 @@
import type { BrowserBookmark } from '../domain/browserBookmark';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
let cachedBookmarks: BrowserBookmark[] | undefined;
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
return cachedBookmarks?.map((item) => ({ ...item }));
}
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
if (!reader) return cachedBookmarks;
const items = await reader();
cachedBookmarks = items;
return getCachedHostBrowserBookmarks();
}
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
if (!writer) return;
await writer(items);
cachedBookmarks = items;
}
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from '../locales/en.ts';
import ru from '../locales/ru.ts';
import es from '../locales/es.ts';
import zhCN from '../locales/zh-CN.ts';
import zhTW from '../locales/zh-TW.ts';
const keys = [
'cloudSync.provider.disconnect',
'cloudSync.provider.disconnect.confirmTitle',
'cloudSync.provider.disconnect.confirmMessage',
'cloudSync.provider.disconnect.confirmAction',
'cloudSync.provider.disconnect.toastTitle',
'cloudSync.provider.disconnect.cloudRemains',
'cloudSync.provider.disconnect.failed',
] as const;
test('disconnect copy exists in every bundled locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
}
}
});
test('disconnect confirm copy states that saved provider credentials are removed', () => {
// The confirm dialog must explain the data consequence of disconnecting:
// locally saved endpoint + credentials removal, not just "stops syncing".
const expected = [
[en, 'will be removed'],
[es, 'eliminarán'],
[ru, 'будут удалены'],
[zhCN, '删除'],
[zhTW, '刪除'],
] as const;
for (const [messages, fragment] of expected) {
assert.ok(
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
`confirmMessage should mention credential removal: ${fragment}`,
);
}
});
test('disconnect toast copy explains the cloud copy is retained', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
assert.ok(
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
'{name}',
),
`${locale} cloudRemains hint should reference the provider name`,
);
}
});
test('disconnect copy distinguishes login cleanup from retained local data', () => {
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
for (const messages of [en, ru, es, zhCN, zhTW]) {
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
}
});
@@ -11,10 +11,16 @@ const strategyKeys = [
"cloudSync.strategy.desc",
"cloudSync.strategy.smartMerge",
"cloudSync.strategy.smartMergeDesc",
"cloudSync.strategy.smartMergePreserve",
"cloudSync.strategy.smartMergePreserveDesc",
"cloudSync.strategy.preferCloud",
"cloudSync.strategy.preferCloudDesc",
"cloudSync.strategy.preferLocal",
"cloudSync.strategy.preferLocalDesc",
"cloudSync.aiSessions.title",
"cloudSync.aiSessions.desc",
"cloudSync.aiSessions.compatibility",
"cloudSync.aiSessions.enableFailed",
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
+2
View File
@@ -1,3 +1,4 @@
import { enMonitorMessages } from './en/monitor';
import type { Messages } from './types';
import { enCoreMessages } from './en/core';
import { enVaultMessages } from './en/vault';
@@ -9,6 +10,7 @@ import { enScriptsMessages } from './en/scripts';
export type { Messages } from './types';
const en: Messages = {
...enMonitorMessages,
...enCoreMessages,
...enVaultMessages,
...enTerminalMessages,
+9
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
@@ -513,6 +514,10 @@ export const enAiMessages: Messages = {
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
'ai.chat.modelCatalogSourceLive': 'Live catalog',
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
@@ -522,6 +527,8 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
@@ -533,6 +540,8 @@ export const enAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.forkFromHere': 'Fork from here',
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
+9
View File
@@ -767,6 +767,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.sidePanel.pane.browser': 'Browser',
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
@@ -1123,6 +1128,7 @@ Highlight the focused split pane:
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
@@ -1148,6 +1154,9 @@ Highlight the focused split pane:
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveSelected': 'Move to group ({count})',
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
+100
View File
@@ -0,0 +1,100 @@
export const enMonitorMessages = {
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
"monitor.password": "Password (this run only)",
"monitor.status.limit":
"Monitoring is limited to 256 unique hosts at a time.",
"sync.entityType.monitorPanels": "monitor panels",
"monitor.title": "Monitoring",
"monitor.create": "New panel",
"monitor.createFirst": "Create your first panel",
"monitor.empty": "Your hosts, at a glance",
"monitor.emptyHint":
"Choose saved hosts from any group. Start monitoring when you are ready.",
"monitor.choosePanel": "Choose a panel",
"monitor.edit": "Edit panel",
"monitor.copy": "Duplicate panel",
"monitor.copyName": "{name} copy",
"monitor.delete": "Delete panel",
"monitor.deleteHint":
"Delete “{name}”? Its monitoring will stop. Saved hosts and other panels are kept.",
"monitor.start": "Start monitoring",
"monitor.stop": "Stop",
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search hosts, groups, tags or notes",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
"monitor.minutes": "Last {count} min",
"monitor.sort": "Sort hosts",
"monitor.sort.manual": "Saved order",
"monitor.sort.name": "Name",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.noMatches": "No matching hosts",
"monitor.latency": "Latency",
"monitor.responseLatency": "Response latency (includes remote processing)",
"monitor.tags": "Tags",
"monitor.notes": "Notes",
"monitor.visibleColumns": "Visible columns",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Resize {column} column",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
"monitor.status": "Status",
"monitor.cpu": "CPU",
"monitor.cores": "Cores: {count}",
"monitor.memory": "Memory",
"monitor.diskCapacity": "{used} used / {total} total",
"monitor.diskCapacityUnknown": "Capacity unavailable",
"monitor.rootDisk": "Root partition",
"monitor.network": "Network",
"monitor.download": "Download",
"monitor.upload": "Upload",
"monitor.updated": "Updated",
"monitor.updatedAt": "Updated {time}",
"monitor.noSamples": "Waiting for the first reading",
"monitor.cpuTrend": "CPU trend",
"monitor.memoryTrend": "Memory trend",
"monitor.downloadTrend": "Download trend",
"monitor.uploadTrend": "Upload trend",
"monitor.name": "Panel name",
"monitor.description": "Description (optional)",
"monitor.chooseHosts": "Choose hosts",
"monitor.selectionHint":
"Choose across groups. Unsupported connections are shown with a clear status when started.",
"monitor.defaultView": "Default view",
"monitor.saveHint":
"Saving keeps configuration only and does not connect. Copies start stopped too.",
"monitor.nameRequired": "Enter a panel name.",
"monitor.hostRequired": "Select at least one host.",
"monitor.duplicateName": "A panel already uses this name.",
"monitor.saveFailed": "Could not save the panel. Please try again.",
"monitor.deleted":
"This panel was deleted in another window. Close the editor and create a new panel.",
"monitor.moveUp": "Move host up",
"monitor.removeHost": "Remove from panel",
"monitor.missingHost": "Deleted host",
"monitor.observed": "Observed for {count} min this run",
"monitor.disks": "Partitions",
"monitor.system": "System information",
"monitor.loginHint":
"Check the host credentials or connection settings, then retry. Identity and sign-in prompts are handled one host at a time.",
"monitor.retry": "Retry connection",
"monitor.unsupportedHint":
"Monitoring needs Linux or macOS over SSH with support for additional command channels.",
"monitor.openTerminal": "Open terminal",
"monitor.confirmHost": "Confirm host identity",
"monitor.status.stopped": "Not started",
"monitor.status.queued": "Queued",
"monitor.status.connecting": "Connecting",
"monitor.status.live": "Up to date",
"monitor.status.login": "Sign-in needed",
"monitor.status.error": "Collection failed",
"monitor.status.unsupported": "Unavailable",
"monitor.status.disconnected": "Disconnected",
"monitor.status.stale": "Out of date",
"monitor.status.missing": "Host deleted",
};
-2
View File
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.sidePanel.viewList': 'List view',
'scripts.sidePanel.viewStacked': 'Stacked view',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
+126 -1
View File
@@ -20,6 +20,9 @@ export const enTerminalMessages: Messages = {
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.browser.title': 'Browser',
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
'terminal.browser.open': 'Open Browser',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
@@ -44,6 +47,10 @@ export const enTerminalMessages: Messages = {
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'history.action.clearAll': 'Clear all history',
'history.clearAll.dialog.title': 'Clear all command history?',
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
'history.clearAll.dialog.confirm': 'Clear all',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -310,8 +317,11 @@ export const enTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Connecting...',
'cloudSync.provider.disconnect': 'Disconnect',
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
'cloudSync.provider.s3': 'S3 Compatible',
@@ -381,6 +391,12 @@ export const enTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Sync strategy',
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
'cloudSync.aiSessions.title': 'Sync AI chat history',
'cloudSync.aiSessions.desc': 'Include AI chat sessions in cloud sync. Messages merge append-only across devices; attachment contents stay on this device.',
'cloudSync.aiSessions.compatibility': 'Older versions may overwrite chat history. Use an updated Netcatty on every synced device.',
'cloudSync.aiSessions.enableFailed': 'Could not update chat sync. Check the cloud connection and available local storage, then try again.',
'cloudSync.strategy.smartMergePreserve': 'Smart merge (never delete)',
'cloudSync.strategy.smartMergePreserveDesc': 'Combine changes from both sides and never propagate a one-sided deletion — an entry survives until it is removed on every device. Recommended when you restore backups or keep several machines in sync.',
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
'cloudSync.strategy.preferCloud': 'Cloud wins',
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
@@ -423,6 +439,12 @@ export const enTerminalMessages: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
'cloudSync.localBackups.clear': 'Clear all backups',
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
@@ -518,6 +540,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Groups',
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
'cloudSync.conflict.entity.settings': 'Settings',
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
@@ -614,6 +637,43 @@ export const enTerminalMessages: Messages = {
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.standardKeys': 'Standard keys',
'keychain.generate.securityKeys': 'Security keys · FIDO2',
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
'keychain.generate.generating': 'Generating…',
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
'fido.prompt.details': 'Request details',
'fido.prompt.showPin': 'Show PIN',
'fido.prompt.hidePin': 'Hide PIN',
'fido.prompt.verifying': 'Verifying…',
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
'fido.prompt.pinTitle': 'Security key PIN',
'fido.prompt.touchTitle': 'Touch your security key',
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Waiting for your key…',
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
"fido.recovery.device-unavailable.title": "Connect the matching security key",
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
"fido.recovery.timeout.title": "Security key confirmation timed out",
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
"fido.recovery.failed.title": "Security key verification did not complete",
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
"fido.recovery.retry": "Retry verification",
"fido.recovery.usePassword": "Use configured password",
"fido.recovery.passwordFallback": "Trying the password configured for this host",
"fido.prompt.cancelled": "Security key authentication cancelled.",
'fido.prompt.touchDone': 'I touched it',
'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.',
'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.',
'keychain.generate.resident': 'Store credentials on the key',
'keychain.generate.verifyRequired': 'Verify PIN each time',
'keychain.action.loadResident': 'Load resident keys from security key',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
@@ -882,4 +942,69 @@ export const enTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
'terminal.browser.newTab': 'New tab',
'terminal.browser.address': 'Web address',
'terminal.browser.back': 'Back',
'terminal.browser.forward': 'Forward',
'terminal.browser.reload': 'Reload',
'terminal.browser.stop': 'Stop',
'terminal.browser.bookmarks': 'Apps & bookmarks',
'terminal.browser.downloads': 'Downloads',
'terminal.browser.empty': 'Save an app or enter an address to get started.',
'terminal.browser.connecting': 'Connecting…',
'terminal.browser.disconnected': 'Connection unavailable',
'terminal.browser.retry': 'Reconnect',
'terminal.browser.addBookmark': 'Add app',
'terminal.browser.editBookmark': 'Edit app',
'terminal.browser.bookmarkTitle': 'Title',
'terminal.browser.icon': 'Icon',
'terminal.browser.website': 'Website',
'terminal.browser.text': 'Text',
'terminal.browser.image': 'Image',
'terminal.browser.chooseImage': 'Choose image',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Show on',
'terminal.browser.hostScope': 'This host',
'terminal.browser.allScope': 'All hosts',
'terminal.browser.changeIcon': 'Change icon',
'terminal.browser.resetIcon': 'Use website icon',
'terminal.browser.downloadEmpty': 'No downloads yet',
'terminal.browser.openFolder': 'Open downloads folder',
'terminal.browser.clearDownloads': 'Clear finished downloads',
'terminal.browser.cancelDownload': 'Cancel download',
'terminal.browser.reveal': 'Show in folder',
'terminal.browser.openFile': 'Open file',
'terminal.browser.removeDownload': 'Remove from list',
'terminal.browser.waiting': 'Preparing download…',
'terminal.browser.progressing': 'Downloading',
'terminal.browser.completed': 'Completed',
'terminal.browser.cancelled': 'Cancelled',
'terminal.browser.interrupted': 'Interrupted',
'terminal.browser.missing': 'File moved or deleted',
'terminal.browser.closeTab': 'Close tab',
'terminal.browser.more': 'More actions',
'terminal.browser.websiteIconHint': 'The website icon appears automatically after you visit it.',
'terminal.browser.iconText': 'Icon text',
'terminal.browser.iconTextHint': 'Use a letter, short name or emoji.',
'terminal.browser.selectColor': 'Use {color}',
'terminal.browser.customColor': 'Custom color',
'terminal.browser.iconPreview': 'Icon preview',
'terminal.browser.savedImage': 'Saved image',
'terminal.browser.processingImage': 'Preparing image…',
'terminal.browser.replaceImage': 'Replace image',
'terminal.browser.removeImage': 'Remove image',
'terminal.browser.uploadImage': 'Choose an image',
'terminal.browser.dropImage': 'or drag and drop it here',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF or ICO · up to 5 MB. Resized automatically.',
'terminal.browser.iconError.file-type': 'Choose a PNG, JPG, WebP, GIF or ICO image.',
'terminal.browser.iconError.file-size': 'This image is too large. Choose a file under 5 MB.',
'terminal.browser.iconError.decode': 'This image could not be read. Try another file.',
'terminal.browser.bookmarkPreviewHint': 'Shown on your browser start page',
'terminal.browser.untitledBookmark': 'Your app',
'terminal.browser.bookmarkTitlePlaceholder': 'e.g. Team docs',
'terminal.browser.websiteAddress': 'Website address',
'terminal.browser.hostScopeHint': 'Show only in this host’s browser.',
'terminal.browser.allScopeHint': 'Show in every host’s browser.',
'terminal.browser.downloadLocation': 'Saved to your Downloads folder',
'terminal.browser.saveAs': 'Save as…',
};
+15
View File
@@ -1018,6 +1018,21 @@ export const enVaultMessages: Messages = {
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Local shell (CMD) host entries
'localShell.subtitle': 'Local Shell',
'localShell.panel.title': 'New Local Shell',
'localShell.panel.title.edit': 'Edit Local Shell',
'localShell.field.label': 'Name',
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'System default shell',
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
'localShell.field.shellEmpty': 'No shells found',
'localShell.field.shellResolved': 'Runs {command}',
'localShell.field.startDir': 'Start directory',
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
'localShell.saveAndConnect': 'Save & Connect',
'hostDetails.plugin.title': 'Plugin connection',
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
+2
View File
@@ -1,3 +1,4 @@
import { esMonitorMessages } from './es/monitor';
import type { Messages } from './types';
import { esCoreMessages } from './es/core';
import { esVaultMessages } from './es/vault';
@@ -9,6 +10,7 @@ import { esScriptsMessages } from './es/scripts';
export type { Messages } from './types';
const es: Messages = {
...esMonitorMessages,
...esCoreMessages,
...esVaultMessages,
...esTerminalMessages,
+9
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const esAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
@@ -504,6 +505,10 @@ export const esAiMessages: Messages = {
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
'ai.chat.modelCatalogSourceLive': 'Catálogo en vivo',
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
@@ -513,6 +518,8 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
@@ -524,6 +531,8 @@ export const esAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.menuHosts': 'Hosts',
+10
View File
@@ -682,6 +682,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
'settings.terminal.behavior.smoothScrolling.desc':
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
'settings.terminal.sidePanel.pane.browser': 'Navegador',
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
@@ -883,6 +888,7 @@ Resalta el panel dividido enfocado:
'settings.shortcuts.binding.new-workspace': 'Nuevo espacio de trabajo',
'settings.shortcuts.binding.snippets': 'Abrir fragmentos',
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
@@ -1081,6 +1087,7 @@ Resalta el panel dividido enfocado:
'vault.tree.expandAll': 'Expandir todo',
'vault.tree.collapseAll': 'Contraer todo',
'vault.hosts.newHost': 'Nuevo host',
'vault.hosts.newLocalShell': 'Shell local (CMD)',
'vault.hosts.newGroup': 'Nuevo grupo',
'vault.hosts.import': 'Importar',
'vault.hosts.export': 'Exportar',
@@ -1106,6 +1113,9 @@ Resalta el panel dividido enfocado:
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
'vault.hosts.connectSelected': 'Conectar ({count})',
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts',
+100
View File
@@ -0,0 +1,100 @@
export const esMonitorMessages = {
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
"monitor.password": "Contraseña (solo esta ejecución)",
"monitor.status.limit":
"Se pueden monitorizar hasta 256 equipos únicos a la vez.",
"sync.entityType.monitorPanels": "paneles de monitorización",
"monitor.title": "Monitorización",
"monitor.create": "Nuevo panel",
"monitor.createFirst": "Crear el primer panel",
"monitor.empty": "Tus equipos de un vistazo",
"monitor.emptyHint":
"Elige equipos guardados de cualquier grupo e inicia cuando quieras.",
"monitor.choosePanel": "Elegir panel",
"monitor.edit": "Editar panel",
"monitor.copy": "Duplicar panel",
"monitor.copyName": "Copia de {name}",
"monitor.delete": "Eliminar panel",
"monitor.deleteHint":
"¿Eliminar «{name}»? Se detendrá su monitorización. Se conservarán los equipos y otros paneles.",
"monitor.start": "Iniciar monitorización",
"monitor.stop": "Detener",
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
"monitor.minutes": "Últimos {count} min",
"monitor.sort": "Ordenar equipos",
"monitor.sort.manual": "Orden guardado",
"monitor.sort.name": "Nombre",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.latency": "Latencia",
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
"monitor.tags": "Etiquetas",
"monitor.notes": "Notas",
"monitor.visibleColumns": "Columnas visibles",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Ajustar la columna {column}",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
"monitor.status": "Estado",
"monitor.cpu": "CPU",
"monitor.cores": "Núcleos: {count}",
"monitor.memory": "Memoria",
"monitor.diskCapacity": "{used} usados / {total} en total",
"monitor.diskCapacityUnknown": "Capacidad no disponible",
"monitor.rootDisk": "Partición raíz",
"monitor.network": "Red",
"monitor.download": "Descarga",
"monitor.upload": "Subida",
"monitor.updated": "Actualizado",
"monitor.updatedAt": "Actualizado: {time}",
"monitor.noSamples": "Esperando la primera lectura",
"monitor.cpuTrend": "Tendencia de CPU",
"monitor.memoryTrend": "Tendencia de memoria",
"monitor.downloadTrend": "Tendencia de descarga",
"monitor.uploadTrend": "Tendencia de subida",
"monitor.name": "Nombre del panel",
"monitor.description": "Descripción (opcional)",
"monitor.chooseHosts": "Elegir equipos",
"monitor.selectionHint":
"Selecciona entre grupos. Las conexiones no compatibles se indicarán al iniciar.",
"monitor.defaultView": "Vista predeterminada",
"monitor.saveHint":
"Guardar conserva la configuración sin conectar. Las copias también empiezan detenidas.",
"monitor.nameRequired": "Escribe un nombre para el panel.",
"monitor.hostRequired": "Selecciona al menos un equipo.",
"monitor.duplicateName": "Ya existe un panel con ese nombre.",
"monitor.saveFailed": "No se pudo guardar el panel. Inténtalo de nuevo.",
"monitor.deleted":
"El panel se eliminó en otra ventana. Cierra el editor y crea uno nuevo.",
"monitor.moveUp": "Subir equipo",
"monitor.removeHost": "Quitar del panel",
"monitor.missingHost": "Equipo eliminado",
"monitor.observed": "Observado durante {count} min",
"monitor.disks": "Particiones",
"monitor.system": "Información del sistema",
"monitor.loginHint":
"Comprueba las credenciales o la conexión y reintenta. Las solicitudes se procesan equipo por equipo.",
"monitor.retry": "Reintentar conexión",
"monitor.unsupportedHint":
"Se necesita Linux o macOS por SSH con canales de comandos adicionales.",
"monitor.openTerminal": "Abrir terminal",
"monitor.confirmHost": "Confirmar identidad",
"monitor.status.stopped": "Sin iniciar",
"monitor.status.queued": "En cola",
"monitor.status.connecting": "Conectando",
"monitor.status.live": "Actualizado",
"monitor.status.login": "Requiere acceso",
"monitor.status.error": "Error de lectura",
"monitor.status.unsupported": "No disponible",
"monitor.status.disconnected": "Desconectado",
"monitor.status.stale": "Sin actualizar",
"monitor.status.missing": "Equipo eliminado",
};
-2
View File
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
'scripts.sidePanel.library': 'Biblioteca',
'scripts.sidePanel.running': 'En ejecución',
'scripts.sidePanel.newScript': 'Nuevo script',
'scripts.sidePanel.viewList': 'Vista de lista',
'scripts.sidePanel.viewStacked': 'Vista apilada',
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
'scripts.running.unnamed': 'Script sin título',
'scripts.running.status.running': 'En ejecución',
+89 -1
View File
@@ -20,6 +20,9 @@ export const esTerminalMessages: Messages = {
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Abrir SFTP',
'terminal.browser.title': 'Navegador',
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
'terminal.browser.open': 'Abrir navegador',
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
@@ -44,6 +47,10 @@ export const esTerminalMessages: Messages = {
'history.action.run': 'Ejecutar en la terminal',
'history.action.saveAsSnippet': 'Guardar como snippet',
'history.action.delete': 'Eliminar del historial',
'history.action.clearAll': 'Borrar todo el historial',
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
'history.clearAll.dialog.confirm': 'Borrar todo',
'terminal.toolbar.library': 'Biblioteca',
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
@@ -309,8 +316,11 @@ export const esTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Conectando...',
'cloudSync.provider.disconnect': 'Desconectar',
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
'cloudSync.provider.s3': 'Compatible con S3',
@@ -380,6 +390,12 @@ export const esTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Estrategia de sincronización',
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
'cloudSync.aiSessions.title': 'Sincronizar el historial de chats de IA',
'cloudSync.aiSessions.desc': 'Incluye las sesiones de chat de IA en la sincronización en la nube. Los mensajes se combinan de forma incremental entre dispositivos; el contenido de los adjuntos permanece en este dispositivo.',
'cloudSync.aiSessions.compatibility': 'Las versiones antiguas pueden sobrescribir el historial. Actualiza Netcatty en todos los dispositivos sincronizados.',
'cloudSync.aiSessions.enableFailed': 'No se pudo actualizar la sincronización del chat. Comprueba la conexión con la nube y el espacio local disponible e inténtalo de nuevo.',
'cloudSync.strategy.smartMergePreserve': 'Fusión inteligente (sin borrar)',
'cloudSync.strategy.smartMergePreserveDesc': 'Combina los cambios de ambos lados y nunca propaga un borrado de un solo lado: una entrada se conserva hasta que se elimina en todos los dispositivos. Recomendado al restaurar copias de seguridad o al usar varios equipos.',
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
'cloudSync.strategy.preferCloud': 'Gana la nube',
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
@@ -422,6 +438,12 @@ export const esTerminalMessages: Messages = {
'cloudSync.history.download': 'Descarga',
'cloudSync.history.resolved': 'Resuelto',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
@@ -517,6 +539,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Grupos',
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
'cloudSync.conflict.entity.settings': 'Configuración',
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
@@ -881,4 +904,69 @@ export const esTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
'terminal.browser.newTab': 'Nueva pestaña',
'terminal.browser.address': 'Dirección web',
'terminal.browser.back': 'Atrás',
'terminal.browser.forward': 'Adelante',
'terminal.browser.reload': 'Recargar',
'terminal.browser.stop': 'Detener',
'terminal.browser.bookmarks': 'Aplicaciones y marcadores',
'terminal.browser.downloads': 'Descargas',
'terminal.browser.empty': 'Añade una aplicación o introduce una dirección.',
'terminal.browser.connecting': 'Conectando…',
'terminal.browser.disconnected': 'Conexión no disponible',
'terminal.browser.retry': 'Reconectar',
'terminal.browser.addBookmark': 'Añadir aplicación',
'terminal.browser.editBookmark': 'Editar aplicación',
'terminal.browser.bookmarkTitle': 'Título',
'terminal.browser.icon': 'Icono',
'terminal.browser.website': 'Sitio web',
'terminal.browser.text': 'Texto',
'terminal.browser.image': 'Imagen',
'terminal.browser.chooseImage': 'Elegir imagen',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Mostrar en',
'terminal.browser.hostScope': 'Este equipo',
'terminal.browser.allScope': 'Todos los equipos',
'terminal.browser.changeIcon': 'Cambiar icono',
'terminal.browser.resetIcon': 'Usar icono del sitio',
'terminal.browser.downloadEmpty': 'No hay descargas',
'terminal.browser.openFolder': 'Abrir carpeta de descargas',
'terminal.browser.clearDownloads': 'Borrar descargas finalizadas',
'terminal.browser.cancelDownload': 'Cancelar descarga',
'terminal.browser.reveal': 'Mostrar en carpeta',
'terminal.browser.openFile': 'Abrir archivo',
'terminal.browser.removeDownload': 'Quitar de la lista',
'terminal.browser.waiting': 'Preparando descarga…',
'terminal.browser.progressing': 'Descargando',
'terminal.browser.completed': 'Completada',
'terminal.browser.cancelled': 'Cancelada',
'terminal.browser.interrupted': 'Interrumpida',
'terminal.browser.missing': 'Archivo movido o eliminado',
'terminal.browser.closeTab': 'Cerrar pestaña',
'terminal.browser.more': 'Más acciones',
'terminal.browser.websiteIconHint': 'El icono se obtiene automáticamente al visitar el sitio.',
'terminal.browser.iconText': 'Texto del icono',
'terminal.browser.iconTextHint': 'Usa una letra, abreviatura o emoji.',
'terminal.browser.selectColor': 'Usar {color}',
'terminal.browser.customColor': 'Color personalizado',
'terminal.browser.iconPreview': 'Vista previa del icono',
'terminal.browser.savedImage': 'Imagen guardada',
'terminal.browser.processingImage': 'Preparando imagen…',
'terminal.browser.replaceImage': 'Cambiar imagen',
'terminal.browser.removeImage': 'Quitar imagen',
'terminal.browser.uploadImage': 'Elegir imagen',
'terminal.browser.dropImage': 'o arrástrala aquí',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF o ICO · hasta 5 MB. Se ajusta automáticamente.',
'terminal.browser.iconError.file-type': 'Elige una imagen PNG, JPG, WebP, GIF o ICO.',
'terminal.browser.iconError.file-size': 'La imagen es demasiado grande. Elige un archivo de hasta 5 MB.',
'terminal.browser.iconError.decode': 'No se pudo leer la imagen. Prueba con otro archivo.',
'terminal.browser.bookmarkPreviewHint': 'Aparece en la página de inicio del navegador',
'terminal.browser.untitledBookmark': 'Tu aplicación',
'terminal.browser.bookmarkTitlePlaceholder': 'p. ej., Documentos del equipo',
'terminal.browser.websiteAddress': 'Dirección del sitio',
'terminal.browser.hostScopeHint': 'Mostrar solo en el navegador de este host.',
'terminal.browser.allScopeHint': 'Mostrar en el navegador de todos los hosts.',
'terminal.browser.downloadLocation': 'Guardado en la carpeta Descargas',
'terminal.browser.saveAs': 'Guardar como…',
};
+15
View File
@@ -1008,6 +1008,21 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
'localShell.panel.title.edit': 'Editar shell local',
'localShell.field.label': 'Nombre',
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
'localShell.field.shellEmpty': 'No se encontraron shells',
'localShell.field.shellResolved': 'Ejecuta {command}',
'localShell.field.startDir': 'Directorio de inicio',
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
'localShell.saveAndConnect': 'Guardar y conectar',
'hostDetails.plugin.title': 'Conexión de plugin',
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { enMonitorMessages } from "./en/monitor";
import { esMonitorMessages } from "./es/monitor";
import { ruMonitorMessages } from "./ru/monitor";
import { zhCNMonitorMessages } from "./zh-CN/monitor";
import { zhTWMonitorMessages } from "./zh-TW/monitor";
test("monitoring UI literal message keys exist, including editor-only controls", () => {
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
for (const key of keys)
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
}
});
for (const [locale, messages] of Object.entries({
es: esMonitorMessages,
ru: ruMonitorMessages,
"zh-CN": zhCNMonitorMessages,
"zh-TW": zhTWMonitorMessages,
})) {
test(`monitoring has translated actions, states and matching placeholders in ${locale}`, () => {
assert.deepEqual(
Object.keys(messages).sort(),
Object.keys(enMonitorMessages).sort(),
);
for (const key of Object.keys(enMonitorMessages)) {
const english = enMonitorMessages[key as keyof typeof enMonitorMessages];
const translated = messages[key as keyof typeof messages];
assert.ok(translated.trim(), key);
assert.deepEqual(
(translated.match(/\{\w+\}/g) ?? []).sort(),
(english.match(/\{\w+\}/g) ?? []).sort(),
key,
);
}
});
}
+2
View File
@@ -1,3 +1,4 @@
import { ruMonitorMessages } from './ru/monitor';
import type { Messages } from './types';
import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
@@ -9,6 +10,7 @@ import { ruScriptsMessages } from './ru/scripts';
export type { Messages } from './types';
const ru: Messages = {
...ruMonitorMessages,
...ruCoreMessages,
...ruVaultMessages,
...ruTerminalMessages,
+9
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
// AI Settings
'ai.agentSettings': 'Настройки агента',
@@ -460,6 +461,10 @@ export const ruAiMessages: Messages = {
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
'ai.chat.modelCatalogSourceLive': 'Актуальный список моделей',
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
@@ -469,6 +474,8 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
@@ -480,6 +487,8 @@ export const ruAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.forkFromHere': 'Ветвление отсюда',
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.menuHosts': 'Хосты',
+6
View File
@@ -718,6 +718,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
'settings.terminal.behavior.smoothScrolling.desc':
'Анимировать прокрутку области терминала вместо мгновенного перехода',
'settings.terminal.sidePanel.pane.browser': 'Браузер',
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
@@ -908,6 +913,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
+99
View File
@@ -0,0 +1,99 @@
export const ruMonitorMessages = {
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
"monitor.password": "Пароль (только для этого запуска)",
"monitor.status.limit": "Одновременно можно наблюдать не более 256 серверов.",
"sync.entityType.monitorPanels": "панели мониторинга",
"monitor.title": "Мониторинг",
"monitor.create": "Новая панель",
"monitor.createFirst": "Создать первую панель",
"monitor.empty": "Все серверы перед глазами",
"monitor.emptyHint":
"Выберите сохранённые серверы из любых групп и запустите мониторинг.",
"monitor.choosePanel": "Выбрать панель",
"monitor.edit": "Изменить панель",
"monitor.copy": "Дублировать панель",
"monitor.copyName": "Копия {name}",
"monitor.delete": "Удалить панель",
"monitor.deleteHint":
"Удалить «{name}»? Мониторинг этой панели остановится. Серверы и другие панели сохранятся.",
"monitor.start": "Начать мониторинг",
"monitor.stop": "Остановить",
"monitor.stopAll": "Остановить всё",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Поиск узлов, групп, тегов и заметок",
"monitor.interval": "Интервал обновления",
"monitor.range": "Период графика",
"monitor.seconds": "Каждые {count} с",
"monitor.minutes": "Последние {count} мин",
"monitor.sort": "Сортировка серверов",
"monitor.sort.manual": "Сохранённый порядок",
"monitor.sort.name": "Имя",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.latency": "Задержка",
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
"monitor.tags": "Теги",
"monitor.notes": "Заметки",
"monitor.visibleColumns": "Видимые столбцы",
"monitor.terminal": "Терминал",
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
"monitor.status": "Состояние",
"monitor.cpu": "ЦП",
"monitor.cores": "Ядер: {count}",
"monitor.memory": "Память",
"monitor.diskCapacity": "Использовано {used} / всего {total}",
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
"monitor.rootDisk": "Корневой раздел",
"monitor.network": "Сеть",
"monitor.download": "Загрузка",
"monitor.upload": "Отправка",
"monitor.updated": "Обновлено",
"monitor.updatedAt": "Обновлено: {time}",
"monitor.noSamples": "Ожидание первого измерения",
"monitor.cpuTrend": "График ЦП",
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
"monitor.selectionHint":
"Можно выбирать из разных групп. Неподдерживаемые подключения будут отмечены при запуске.",
"monitor.defaultView": "Вид по умолчанию",
"monitor.saveHint":
"Сохранение не подключает серверы. Копии также создаются остановленными.",
"monitor.nameRequired": "Введите название панели.",
"monitor.hostRequired": "Выберите хотя бы один сервер.",
"monitor.duplicateName": "Панель с таким названием уже существует.",
"monitor.saveFailed": "Не удалось сохранить панель. Повторите попытку.",
"monitor.deleted":
"Панель удалена в другом окне. Закройте редактор и создайте новую.",
"monitor.moveUp": "Переместить выше",
"monitor.removeHost": "Убрать из панели",
"monitor.missingHost": "Удалённый сервер",
"monitor.observed": "Наблюдение: {count} мин",
"monitor.disks": "Разделы дисков",
"monitor.system": "Информация о системе",
"monitor.loginHint":
"Проверьте данные входа и настройки подключения. Подтверждения обрабатываются по одному серверу.",
"monitor.retry": "Повторить подключение",
"monitor.unsupportedHint":
"Для мониторинга нужен Linux или macOS по SSH с дополнительными каналами команд.",
"monitor.openTerminal": "Открыть терминал",
"monitor.confirmHost": "Подтвердить сервер",
"monitor.status.stopped": "Не запущено",
"monitor.status.queued": "В очереди",
"monitor.status.connecting": "Подключение",
"monitor.status.live": "Актуально",
"monitor.status.login": "Нужен вход",
"monitor.status.error": "Ошибка сбора",
"monitor.status.unsupported": "Недоступно",
"monitor.status.disconnected": "Отключено",
"monitor.status.stale": "Устарело",
"monitor.status.missing": "Сервер удалён",
};
-2
View File
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
'scripts.sidePanel.library': 'Библиотека',
'scripts.sidePanel.running': 'Выполняются',
'scripts.sidePanel.newScript': 'Новый скрипт',
'scripts.sidePanel.viewList': 'Список',
'scripts.sidePanel.viewStacked': 'Плитка',
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
'scripts.running.unnamed': 'Скрипт без названия',
'scripts.running.status.running': 'Выполняется',
+126 -1
View File
@@ -41,6 +41,9 @@ export const ruTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.browser.title': 'Браузер',
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
'terminal.browser.open': 'Открыть браузер',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
@@ -65,6 +68,10 @@ export const ruTerminalMessages: Messages = {
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'history.action.clearAll': 'Очистить всю историю',
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
'history.clearAll.dialog.confirm': 'Очистить всё',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -328,8 +335,11 @@ export const ruTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Подключение...',
'cloudSync.provider.disconnect': 'Отключить',
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
'cloudSync.provider.s3': 'Совместимое с S3',
@@ -399,6 +409,12 @@ export const ruTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Стратегия синхронизации',
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
'cloudSync.aiSessions.title': 'Синхронизировать историю ИИ-чатов',
'cloudSync.aiSessions.desc': 'Включать сессии ИИ-чатов в облачную синхронизацию. Сообщения объединяются дополнением между устройствами; содержимое вложений остаётся на этом устройстве.',
'cloudSync.aiSessions.compatibility': 'Старые версии могут перезаписать историю. Обновите Netcatty на всех синхронизируемых устройствах.',
'cloudSync.aiSessions.enableFailed': 'Не удалось изменить синхронизацию чатов. Проверьте подключение к облаку и свободное место на устройстве, затем повторите попытку.',
'cloudSync.strategy.smartMergePreserve': 'Умное слияние (без удалений)',
'cloudSync.strategy.smartMergePreserveDesc': 'Объединяет изменения с обеих сторон и никогда не передаёт удаление с одной стороны: запись сохраняется, пока её не убрали на всех устройствах. Рекомендуется при восстановлении из резервных копий или при работе с несколькими машинами.',
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
'cloudSync.strategy.preferCloud': 'Приоритет облака',
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
@@ -441,6 +457,12 @@ export const ruTerminalMessages: Messages = {
'cloudSync.history.download': 'Скачивание',
'cloudSync.history.resolved': 'Разрешено',
'cloudSync.history.error': 'Ошибка',
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
'cloudSync.localBackups.clear': 'Удалить все копии',
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
@@ -536,6 +558,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Группы',
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
'cloudSync.conflict.entity.settings': 'Настройки',
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
@@ -629,6 +652,43 @@ export const ruTerminalMessages: Messages = {
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.standardKeys': 'Обычные ключи',
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
'keychain.generate.generating': 'Создание…',
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
'fido.prompt.details': 'Подробности запроса',
'fido.prompt.showPin': 'Показать PIN',
'fido.prompt.hidePin': 'Скрыть PIN',
'fido.prompt.verifying': 'Проверка…',
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
'fido.prompt.pinTitle': 'PIN ключа безопасности',
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
"fido.recovery.failed.title": "Проверка ключа не завершена",
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
"fido.recovery.retry": "Повторить проверку",
"fido.recovery.usePassword": "Использовать сохранённый пароль",
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
'fido.prompt.touchDone': 'Я коснулся',
'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.',
'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.',
'keychain.generate.resident': 'Хранить учётные данные на ключе',
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
@@ -889,4 +949,69 @@ export const ruTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
'terminal.browser.newTab': 'Новая вкладка',
'terminal.browser.address': 'Веб-адрес',
'terminal.browser.back': 'Назад',
'terminal.browser.forward': 'Вперёд',
'terminal.browser.reload': 'Обновить',
'terminal.browser.stop': 'Остановить',
'terminal.browser.bookmarks': 'Приложения и закладки',
'terminal.browser.downloads': 'Загрузки',
'terminal.browser.empty': 'Добавьте приложение или введите адрес.',
'terminal.browser.connecting': 'Подключение…',
'terminal.browser.disconnected': 'Соединение недоступно',
'terminal.browser.retry': 'Подключиться снова',
'terminal.browser.addBookmark': 'Добавить приложение',
'terminal.browser.editBookmark': 'Изменить приложение',
'terminal.browser.bookmarkTitle': 'Название',
'terminal.browser.icon': 'Значок',
'terminal.browser.website': 'Значок сайта',
'terminal.browser.text': 'Текст',
'terminal.browser.image': 'Изображение',
'terminal.browser.chooseImage': 'Выбрать изображение',
'terminal.browser.color': 'Цвет',
'terminal.browser.scope': 'Показывать на',
'terminal.browser.hostScope': 'Этот хост',
'terminal.browser.allScope': 'Все хосты',
'terminal.browser.changeIcon': 'Изменить значок',
'terminal.browser.resetIcon': 'Использовать значок сайта',
'terminal.browser.downloadEmpty': 'Загрузок пока нет',
'terminal.browser.openFolder': 'Открыть папку загрузок',
'terminal.browser.clearDownloads': 'Очистить завершённые загрузки',
'terminal.browser.cancelDownload': 'Отменить загрузку',
'terminal.browser.reveal': 'Показать в папке',
'terminal.browser.openFile': 'Открыть файл',
'terminal.browser.removeDownload': 'Удалить из списка',
'terminal.browser.waiting': 'Подготовка загрузки…',
'terminal.browser.progressing': 'Загрузка',
'terminal.browser.completed': 'Завершена',
'terminal.browser.cancelled': 'Отменена',
'terminal.browser.interrupted': 'Прервана',
'terminal.browser.missing': 'Файл перемещён или удалён',
'terminal.browser.closeTab': 'Закрыть вкладку',
'terminal.browser.more': 'Другие действия',
'terminal.browser.websiteIconHint': 'Значок загрузится автоматически после посещения сайта.',
'terminal.browser.iconText': 'Текст значка',
'terminal.browser.iconTextHint': 'Буква, сокращение или эмодзи.',
'terminal.browser.selectColor': 'Выбрать {color}',
'terminal.browser.customColor': 'Свой цвет',
'terminal.browser.iconPreview': 'Предпросмотр значка',
'terminal.browser.savedImage': 'Сохранённое изображение',
'terminal.browser.processingImage': 'Обработка изображения…',
'terminal.browser.replaceImage': 'Заменить',
'terminal.browser.removeImage': 'Удалить изображение',
'terminal.browser.uploadImage': 'Выбрать изображение',
'terminal.browser.dropImage': 'или перетащить его сюда',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF или ICO · до 5 МБ. Размер подбирается автоматически.',
'terminal.browser.iconError.file-type': 'Выберите PNG, JPG, WebP, GIF или ICO.',
'terminal.browser.iconError.file-size': 'Изображение слишком большое. Выберите файл до 5 МБ.',
'terminal.browser.iconError.decode': 'Не удалось прочитать изображение. Выберите другой файл.',
'terminal.browser.bookmarkPreviewHint': 'Отображается на начальной странице браузера',
'terminal.browser.untitledBookmark': 'Ваше приложение',
'terminal.browser.bookmarkTitlePlaceholder': 'Например, Документы команды',
'terminal.browser.websiteAddress': 'Адрес сайта',
'terminal.browser.hostScopeHint': 'Только в браузере текущего хоста.',
'terminal.browser.allScopeHint': 'В браузерах всех хостов.',
'terminal.browser.downloadLocation': 'Файлы сохраняются в папку «Загрузки»',
'terminal.browser.saveAs': 'Сохранить как…',
};
+19
View File
@@ -102,6 +102,7 @@ export const ruVaultMessages: Messages = {
'vault.tree.expandAll': 'Развернуть все',
'vault.tree.collapseAll': 'Свернуть все',
'vault.hosts.newHost': 'Новый хост',
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
'vault.hosts.newGroup': 'Новая группа',
'vault.hosts.import': 'Импорт',
'vault.hosts.export': 'Экспорт',
@@ -127,6 +128,9 @@ export const ruVaultMessages: Messages = {
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
@@ -1051,6 +1055,21 @@ export const ruVaultMessages: Messages = {
'hostDetails.addTag': 'Добавить тег...',
'hostDetails.createTag': 'Создать тег',
'hostDetails.createGroup': 'Создать группу',
// Записи локальной оболочки (CMD) в хранилище
'localShell.subtitle': 'Локальная оболочка',
'localShell.panel.title': 'Новая локальная оболочка',
'localShell.panel.title.edit': 'Изменить локальную оболочку',
'localShell.field.label': 'Название',
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
'localShell.field.shell': 'Оболочка',
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
'localShell.field.shellEmpty': 'Оболочки не найдены',
'localShell.field.shellResolved': 'Запускает {command}',
'localShell.field.startDir': 'Начальный каталог',
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
'localShell.saveAndConnect': 'Сохранить и открыть',
'hostDetails.plugin.title': 'Подключение плагина',
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
@@ -1,16 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
test('scripts side panel view-toggle copy exists in every locale', () => {
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
}
});
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
+2
View File
@@ -1,3 +1,4 @@
import { zhCNMonitorMessages } from './zh-CN/monitor';
import type { Messages } from './types';
import { zhCNCoreMessages } from './zh-CN/core';
import { zhCNVaultMessages } from './zh-CN/vault';
@@ -9,6 +10,7 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
export type { Messages } from './types';
const zhCN: Messages = {
...zhCNMonitorMessages,
...zhCNCoreMessages,
...zhCNVaultMessages,
...zhCNTerminalMessages,
+9
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
@@ -513,6 +514,10 @@ export const zhCNAiMessages: Messages = {
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
'ai.chat.modelCatalogSourceLive': '实时目录',
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
@@ -522,6 +527,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
@@ -533,6 +540,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.forkFromHere': '从此处分叉',
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
+4
View File
@@ -740,6 +740,7 @@ export const zhCNCoreMessages: Messages = {
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newLocalShell': '本地终端 (CMD)',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
@@ -765,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
+95
View File
@@ -0,0 +1,95 @@
export const zhCNMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
"monitor.password": "密码(仅本次使用)",
"monitor.status.limit": "同时最多监控 256 台不同主机。",
"sync.entityType.monitorPanels": "监控面板",
"monitor.title": "监控",
"monitor.create": "新建面板",
"monitor.createFirst": "创建第一个监控面板",
"monitor.empty": "一眼查看多台主机",
"monitor.emptyHint": "跨分组选择已有主机,准备好后再开始监控。",
"monitor.choosePanel": "选择面板",
"monitor.edit": "编辑面板",
"monitor.copy": "复制面板",
"monitor.copyName": "{name} 副本",
"monitor.delete": "删除面板",
"monitor.deleteHint":
"删除“{name}”?该面板将停止监控,已有主机和其他面板会保留。",
"monitor.start": "开始监控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索主机、分组、标签或备注",
"monitor.interval": "更新频率",
"monitor.range": "趋势范围",
"monitor.seconds": "每 {count} 秒",
"monitor.minutes": "最近 {count} 分钟",
"monitor.sort": "主机排序",
"monitor.sort.manual": "保存的顺序",
"monitor.sort.name": "名称",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.noMatches": "没有符合条件的主机",
"monitor.latency": "延迟",
"monitor.responseLatency": "响应延迟(含远端处理)",
"monitor.tags": "标签",
"monitor.notes": "备注",
"monitor.visibleColumns": "显示的列",
"monitor.terminal": "终端",
"monitor.resizeColumn": "调整「{column}」列宽",
"monitor.host": "主机",
"monitor.hosts": "主机",
"monitor.status": "状态",
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核",
"monitor.memory": "内存",
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
"monitor.diskCapacityUnknown": "暂无容量读数",
"monitor.rootDisk": "根分区",
"monitor.network": "网络",
"monitor.download": "下载",
"monitor.upload": "上传",
"monitor.updated": "更新时间",
"monitor.updatedAt": "更新于 {time}",
"monitor.noSamples": "等待第一次读数",
"monitor.cpuTrend": "CPU 趋势",
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
"monitor.selectionHint": "可跨分组选择。开始后会明确标出不支持的连接。",
"monitor.defaultView": "默认展示",
"monitor.saveHint": "保存仅保留配置,不会连接。复制的面板也不会自动开始。",
"monitor.nameRequired": "请输入面板名称。",
"monitor.hostRequired": "请至少选择一台主机。",
"monitor.duplicateName": "已有同名面板,请换个名称。",
"monitor.saveFailed": "面板未能保存,请重试。",
"monitor.deleted": "该面板已在其他窗口删除。请关闭编辑后新建面板。",
"monitor.moveUp": "主机上移",
"monitor.removeHost": "从面板移除",
"monitor.missingHost": "已删除的主机",
"monitor.observed": "本次已观察 {count} 分钟",
"monitor.disks": "磁盘分区",
"monitor.system": "系统信息",
"monitor.loginHint":
"请检查主机的登录信息或连接设置后重试。身份确认和登录提示会逐台处理。",
"monitor.retry": "重试连接",
"monitor.unsupportedHint":
"监控需要支持额外命令通道的 Linux 或 macOS SSH 连接。",
"monitor.openTerminal": "打开终端",
"monitor.confirmHost": "确认主机身份",
"monitor.status.stopped": "未开始",
"monitor.status.queued": "排队中",
"monitor.status.connecting": "连接中",
"monitor.status.live": "已更新",
"monitor.status.login": "需要登录",
"monitor.status.error": "采集失败",
"monitor.status.unsupported": "不支持",
"monitor.status.disconnected": "已断开",
"monitor.status.stale": "读数过期",
"monitor.status.missing": "主机已删除",
};
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.sidePanel.viewList': '列表视图',
'scripts.sidePanel.viewStacked': '堆叠视图',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
+114 -2
View File
@@ -63,6 +63,10 @@ export const zhCNTerminalMessages: Messages = {
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
'history.action.clearAll': '清空全部历史',
'history.clearAll.dialog.title': '清空全部命令历史?',
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -399,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.sidePanel.pane.browser': '浏览器',
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
@@ -594,6 +603,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
@@ -706,13 +716,50 @@ export const zhCNTerminalMessages: Messages = {
'keychain.export.scriptRequired': '脚本 *',
'keychain.export.exportToHost': '导出到主机',
'keychain.export.failedGeneric': '导出失败:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.label': '名称',
'keychain.field.labelRequired': '名称 *',
'keychain.field.labelPlaceholder': '密钥 Label',
'keychain.field.privateKeyRequired': '私钥 *',
'keychain.field.publicKey': '公钥',
'keychain.field.certificatePlaceholder': '证书内容(可选)',
'keychain.generate.keyType': '密钥类型',
'keychain.generate.standardKeys': '标准密钥',
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
'keychain.generate.generating': '正在生成…',
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
'fido.prompt.details': '查看请求详情',
'fido.prompt.showPin': '显示 PIN',
'fido.prompt.hidePin': '隐藏 PIN',
'fido.prompt.verifying': '正在验证…',
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
'fido.prompt.pinTitle': '安全密钥 PIN',
'fido.prompt.touchTitle': '请触摸安全密钥',
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全密钥响应…',
'fido.prompt.touchAutomatic': '触摸后将自动继续',
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
"fido.recovery.timeout.title": "等待安全密钥确认超时",
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
"fido.recovery.failed.title": "安全密钥验证未完成",
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
"fido.recovery.retry": "重试验证",
"fido.recovery.usePassword": "改用已配置的密码",
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
"fido.prompt.cancelled": "已取消安全密钥验证。",
'fido.prompt.touchDone': '已触摸',
'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。',
'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。',
'keychain.generate.resident': '在设备上保存凭据',
'keychain.generate.verifyRequired': '每次验证 PIN',
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
'keychain.generate.keySize': '密钥长度',
'keychain.generate.labelPlaceholder': '密钥 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
@@ -981,4 +1028,69 @@ export const zhCNTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '保存并关闭',
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
'terminal.browser.newTab': '新标签页',
'terminal.browser.address': '输入网址',
'terminal.browser.back': '后退',
'terminal.browser.forward': '前进',
'terminal.browser.reload': '刷新',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '应用与书签',
'terminal.browser.downloads': '下载',
'terminal.browser.empty': '添加应用或输入网址即可开始浏览。',
'terminal.browser.connecting': '正在连接…',
'terminal.browser.disconnected': '连接不可用',
'terminal.browser.retry': '重新连接',
'terminal.browser.addBookmark': '添加应用',
'terminal.browser.editBookmark': '编辑应用',
'terminal.browser.bookmarkTitle': '名称',
'terminal.browser.icon': '图标',
'terminal.browser.website': '网站图标',
'terminal.browser.text': '文字',
'terminal.browser.image': '图片',
'terminal.browser.chooseImage': '选择图片',
'terminal.browser.color': '颜色',
'terminal.browser.scope': '显示范围',
'terminal.browser.hostScope': '此主机',
'terminal.browser.allScope': '所有主机',
'terminal.browser.changeIcon': '更改图标',
'terminal.browser.resetIcon': '使用网站图标',
'terminal.browser.downloadEmpty': '暂无下载',
'terminal.browser.openFolder': '打开下载文件夹',
'terminal.browser.clearDownloads': '清除已结束的下载',
'terminal.browser.cancelDownload': '取消下载',
'terminal.browser.reveal': '在文件夹中显示',
'terminal.browser.openFile': '打开文件',
'terminal.browser.removeDownload': '从列表移除',
'terminal.browser.waiting': '正在准备下载…',
'terminal.browser.progressing': '正在下载',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中断',
'terminal.browser.missing': '文件已移动或删除',
'terminal.browser.closeTab': '关闭标签页',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '访问网站后,自动获取并显示网站图标。',
'terminal.browser.iconText': '图标文字',
'terminal.browser.iconTextHint': '可以使用字母、简称或表情。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自定义颜色',
'terminal.browser.iconPreview': '图标预览',
'terminal.browser.savedImage': '已保存的图片',
'terminal.browser.processingImage': '正在处理图片…',
'terminal.browser.replaceImage': '替换图片',
'terminal.browser.removeImage': '移除图片',
'terminal.browser.uploadImage': '选择图片',
'terminal.browser.dropImage': '也可以将图片拖到这里',
'terminal.browser.imageHint': '支持 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自动适配图标大小。',
'terminal.browser.iconError.file-type': '请选择 PNG、JPG、WebP、GIF 或 ICO 图片。',
'terminal.browser.iconError.file-size': '图片太大,请选择不超过 5 MB 的文件。',
'terminal.browser.iconError.decode': '无法读取这张图片,请换一个文件。',
'terminal.browser.bookmarkPreviewHint': '显示在浏览器开始页',
'terminal.browser.untitledBookmark': '应用预览',
'terminal.browser.bookmarkTitlePlaceholder': '例如,团队文档',
'terminal.browser.websiteAddress': '网址',
'terminal.browser.hostScopeHint': '仅在当前主机的浏览器中显示。',
'terminal.browser.allScopeHint': '在每台主机的浏览器中显示。',
'terminal.browser.downloadLocation': '文件保存在系统“下载”文件夹',
'terminal.browser.saveAs': '另存为…',
};
+35 -1
View File
@@ -390,6 +390,21 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// 本地终端(CMD)主机条目
'localShell.subtitle': '本地终端',
'localShell.panel.title': '新建本地终端',
'localShell.panel.title.edit': '编辑本地终端',
'localShell.field.label': '名称',
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系统默认 Shell',
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
'localShell.field.shellEmpty': '未发现可用 Shell',
'localShell.field.shellResolved': '启动 {command}',
'localShell.field.startDir': '启动目录',
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
'localShell.saveAndConnect': '保存并打开',
'hostDetails.plugin.title': '插件连接',
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
@@ -453,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.browser.title': '浏览器',
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
'terminal.browser.open': '打开浏览器',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
@@ -702,8 +720,11 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.provider.connecting': '连接中...',
'cloudSync.provider.disconnect': '断开连接',
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
'cloudSync.provider.disconnect.confirmAction': '断开连接',
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
'cloudSync.provider.s3': 'S3 兼容存储',
@@ -773,6 +794,12 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
'cloudSync.aiSessions.title': '同步 AI 对话记录',
'cloudSync.aiSessions.desc': '把 AI 对话记录纳入云同步。多设备的对话会按追加方式合并,附件内容仍保留在本机。',
'cloudSync.aiSessions.compatibility': '旧版本可能覆盖对话记录,请先更新所有参与同步的设备。',
'cloudSync.aiSessions.enableFailed': '无法更改聊天同步设置,请检查云端连接和本地可用空间,然后重试。',
'cloudSync.strategy.smartMergePreserve': '智能合并(不传播删除)',
'cloudSync.strategy.smartMergePreserveDesc': '合并两边的变化,且单侧删除不会同步到其它设备——只有所有设备都删掉才会真正消失。恢复备份或多台机器同步时推荐使用。',
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
'cloudSync.strategy.preferCloud': '云端优先',
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
@@ -815,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
'cloudSync.localBackups.clear': '清空全部备份',
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
@@ -909,6 +942,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.conflict.entity.customGroups': '分组',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
'cloudSync.conflict.entity.groupConfigs': '分组设置',
'cloudSync.conflict.entity.settings': '设置',
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
+2
View File
@@ -1,3 +1,4 @@
import { zhTWMonitorMessages } from './zh-TW/monitor';
import type { Messages } from './types';
import { zhTWCoreMessages } from './zh-TW/core';
import { zhTWVaultMessages } from './zh-TW/vault';
@@ -9,6 +10,7 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
export type { Messages } from './types';
const zhTW: Messages = {
...zhTWMonitorMessages,
...zhTWCoreMessages,
...zhTWVaultMessages,
...zhTWTerminalMessages,
+9
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhTWAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
@@ -503,6 +504,10 @@ export const zhTWAiMessages: Messages = {
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
'ai.chat.modelCatalogSourceLive': '即時目錄',
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
@@ -512,6 +517,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
@@ -523,6 +530,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.forkFromHere': '從此處分叉',
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
+4
View File
@@ -742,6 +742,7 @@ export const zhTWCoreMessages: Messages = {
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新增主機',
'vault.hosts.newLocalShell': '本地終端 (CMD)',
'vault.hosts.newGroup': '新增群組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
@@ -767,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
+95
View File
@@ -0,0 +1,95 @@
export const zhTWMonitorMessages = {
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
"monitor.password": "密碼(僅本次使用)",
"monitor.status.limit": "同時最多監控 256 台不同主機。",
"sync.entityType.monitorPanels": "監控面板",
"monitor.title": "監控",
"monitor.create": "新增面板",
"monitor.createFirst": "建立第一個監控面板",
"monitor.empty": "一眼查看多台主機",
"monitor.emptyHint": "跨群組選擇現有主機,準備好後再開始監控。",
"monitor.choosePanel": "選擇面板",
"monitor.edit": "編輯面板",
"monitor.copy": "複製面板",
"monitor.copyName": "{name} 副本",
"monitor.delete": "刪除面板",
"monitor.deleteHint":
"刪除「{name}」?此面板將停止監控,現有主機和其他面板會保留。",
"monitor.start": "開始監控",
"monitor.stop": "停止",
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋主機、群組、標籤或備註",
"monitor.interval": "更新頻率",
"monitor.range": "趨勢範圍",
"monitor.seconds": "每 {count} 秒",
"monitor.minutes": "最近 {count} 分鐘",
"monitor.sort": "主機排序",
"monitor.sort.manual": "儲存的順序",
"monitor.sort.name": "名稱",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.latency": "延遲",
"monitor.responseLatency": "回應延遲(含遠端處理)",
"monitor.tags": "標籤",
"monitor.notes": "備註",
"monitor.visibleColumns": "顯示的欄位",
"monitor.terminal": "終端",
"monitor.resizeColumn": "調整「{column}」欄寬",
"monitor.host": "主機",
"monitor.hosts": "主機",
"monitor.status": "狀態",
"monitor.cpu": "CPU",
"monitor.cores": "{count} 核心",
"monitor.memory": "記憶體",
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
"monitor.diskCapacityUnknown": "暫無容量讀數",
"monitor.rootDisk": "根分割區",
"monitor.network": "網路",
"monitor.download": "下載",
"monitor.upload": "上傳",
"monitor.updated": "更新時間",
"monitor.updatedAt": "更新於 {time}",
"monitor.noSamples": "等待第一次讀數",
"monitor.cpuTrend": "CPU 趨勢",
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
"monitor.selectionHint": "可跨群組選擇。開始後會明確標示不支援的連線。",
"monitor.defaultView": "預設檢視",
"monitor.saveHint": "儲存僅保留設定,不會連線。複製的面板也不會自動開始。",
"monitor.nameRequired": "請輸入面板名稱。",
"monitor.hostRequired": "請至少選擇一台主機。",
"monitor.duplicateName": "已有同名面板,請換個名稱。",
"monitor.saveFailed": "面板無法儲存,請重試。",
"monitor.deleted": "此面板已在其他視窗刪除。請關閉編輯後新增面板。",
"monitor.moveUp": "主機上移",
"monitor.removeHost": "從面板移除",
"monitor.missingHost": "已刪除的主機",
"monitor.observed": "本次已觀察 {count} 分鐘",
"monitor.disks": "磁碟分割區",
"monitor.system": "系統資訊",
"monitor.loginHint":
"請檢查主機的登入資訊或連線設定後重試。身分確認與登入提示會逐台處理。",
"monitor.retry": "重試連線",
"monitor.unsupportedHint":
"監控需要支援額外命令通道的 Linux 或 macOS SSH 連線。",
"monitor.openTerminal": "開啟終端機",
"monitor.confirmHost": "確認主機身分",
"monitor.status.stopped": "未開始",
"monitor.status.queued": "排程中",
"monitor.status.connecting": "連線中",
"monitor.status.live": "已更新",
"monitor.status.login": "需要登入",
"monitor.status.error": "採集失敗",
"monitor.status.unsupported": "不支援",
"monitor.status.disconnected": "已中斷",
"monitor.status.stale": "讀數過期",
"monitor.status.missing": "主機已刪除",
};
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新增指令碼',
'scripts.sidePanel.viewList': '列表檢視',
'scripts.sidePanel.viewStacked': '堆疊檢視',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
+114 -2
View File
@@ -63,6 +63,10 @@ export const zhTWTerminalMessages: Messages = {
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
'history.action.clearAll': '清空全部歷史',
'history.clearAll.dialog.title': '清空全部指令歷史?',
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
@@ -399,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
@@ -594,6 +603,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.new-workspace': '新增工作區',
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
@@ -706,13 +716,50 @@ export const zhTWTerminalMessages: Messages = {
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.label': '名稱',
'keychain.field.labelRequired': '名稱 *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.standardKeys': '標準金鑰',
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
'keychain.generate.generating': '正在產生…',
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
'fido.prompt.details': '檢視請求詳情',
'fido.prompt.showPin': '顯示 PIN',
'fido.prompt.hidePin': '隱藏 PIN',
'fido.prompt.verifying': '正在驗證…',
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
'fido.prompt.pinTitle': '安全金鑰 PIN',
'fido.prompt.touchTitle': '請觸摸安全金鑰',
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
"fido.recovery.failed.title": "安全金鑰驗證未完成",
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
"fido.recovery.retry": "重試驗證",
"fido.recovery.usePassword": "改用已設定的密碼",
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
'fido.prompt.touchDone': '已觸摸',
'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。',
'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。',
'keychain.generate.resident': '在裝置上儲存憑證',
'keychain.generate.verifyRequired': '每次驗證 PIN',
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
@@ -981,4 +1028,69 @@ export const zhTWTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '儲存並關閉',
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
'terminal.browser.newTab': '新增分頁',
'terminal.browser.address': '輸入網址',
'terminal.browser.back': '上一頁',
'terminal.browser.forward': '下一頁',
'terminal.browser.reload': '重新整理',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '應用程式與書籤',
'terminal.browser.downloads': '下載',
'terminal.browser.empty': '新增應用程式或輸入網址即可開始瀏覽。',
'terminal.browser.connecting': '正在連線…',
'terminal.browser.disconnected': '連線無法使用',
'terminal.browser.retry': '重新連線',
'terminal.browser.addBookmark': '新增應用程式',
'terminal.browser.editBookmark': '編輯應用程式',
'terminal.browser.bookmarkTitle': '名稱',
'terminal.browser.icon': '圖示',
'terminal.browser.website': '網站圖示',
'terminal.browser.text': '文字',
'terminal.browser.image': '圖片',
'terminal.browser.chooseImage': '選擇圖片',
'terminal.browser.color': '顏色',
'terminal.browser.scope': '顯示範圍',
'terminal.browser.hostScope': '此主機',
'terminal.browser.allScope': '所有主機',
'terminal.browser.changeIcon': '變更圖示',
'terminal.browser.resetIcon': '使用網站圖示',
'terminal.browser.downloadEmpty': '尚無下載',
'terminal.browser.openFolder': '開啟下載資料夾',
'terminal.browser.clearDownloads': '清除已結束的下載',
'terminal.browser.cancelDownload': '取消下載',
'terminal.browser.reveal': '在資料夾中顯示',
'terminal.browser.openFile': '開啟檔案',
'terminal.browser.removeDownload': '從清單移除',
'terminal.browser.waiting': '正在準備下載…',
'terminal.browser.progressing': '正在下載',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中斷',
'terminal.browser.missing': '檔案已移動或刪除',
'terminal.browser.closeTab': '關閉分頁',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '造訪網站後,自動取得並顯示網站圖示。',
'terminal.browser.iconText': '圖示文字',
'terminal.browser.iconTextHint': '可以使用字母、簡稱或表情符號。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自訂顏色',
'terminal.browser.iconPreview': '圖示預覽',
'terminal.browser.savedImage': '已儲存的圖片',
'terminal.browser.processingImage': '正在處理圖片…',
'terminal.browser.replaceImage': '替換圖片',
'terminal.browser.removeImage': '移除圖片',
'terminal.browser.uploadImage': '選擇圖片',
'terminal.browser.dropImage': '也可以將圖片拖到這裡',
'terminal.browser.imageHint': '支援 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自動調整圖示大小。',
'terminal.browser.iconError.file-type': '請選擇 PNG、JPG、WebP、GIF 或 ICO 圖片。',
'terminal.browser.iconError.file-size': '圖片太大,請選擇不超過 5 MB 的檔案。',
'terminal.browser.iconError.decode': '無法讀取這張圖片,請更換檔案。',
'terminal.browser.bookmarkPreviewHint': '顯示在瀏覽器起始頁',
'terminal.browser.untitledBookmark': '應用程式預覽',
'terminal.browser.bookmarkTitlePlaceholder': '例如,團隊文件',
'terminal.browser.websiteAddress': '網址',
'terminal.browser.hostScopeHint': '僅在目前主機的瀏覽器中顯示。',
'terminal.browser.allScopeHint': '在每台主機的瀏覽器中顯示。',
'terminal.browser.downloadLocation': '檔案儲存在系統「下載項目」資料夾',
'terminal.browser.saveAs': '另存新檔…',
};
+35 -1
View File
@@ -390,6 +390,21 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立群組',
// 本地終端(CMD)主機項目
'localShell.subtitle': '本地終端',
'localShell.panel.title': '新增本地終端',
'localShell.panel.title.edit': '編輯本地終端',
'localShell.field.label': '名稱',
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系統預設 Shell',
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
'localShell.field.shellEmpty': '未找到可用的 Shell',
'localShell.field.shellResolved': '啟動 {command}',
'localShell.field.startDir': '啟動目錄',
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
'localShell.saveAndConnect': '儲存並開啟',
'hostDetails.plugin.title': '外掛程式連線',
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
@@ -453,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '開啟 SFTP',
'terminal.browser.title': '瀏覽器',
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
'terminal.browser.open': '開啟瀏覽器',
'terminal.toolbar.availableAfterConnect': '連線後可用',
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
@@ -702,8 +720,11 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.provider.connecting': '連線中...',
'cloudSync.provider.disconnect': '中斷連線',
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
'cloudSync.provider.s3': 'S3 相容儲存',
@@ -773,6 +794,12 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
'cloudSync.aiSessions.title': '同步 AI 對話記錄',
'cloudSync.aiSessions.desc': '把 AI 對話記錄納入雲同步。多裝置的對話會按追加方式合併,附件內容仍保留在本機。',
'cloudSync.aiSessions.compatibility': '舊版本可能覆寫對話記錄,請先更新所有參與同步的裝置。',
'cloudSync.aiSessions.enableFailed': '無法變更聊天同步設定,請檢查雲端連線與本機可用空間,然後重試。',
'cloudSync.strategy.smartMergePreserve': '智慧合併(不傳播刪除)',
'cloudSync.strategy.smartMergePreserveDesc': '合併兩邊的變化,且單側刪除不會同步到其它裝置——只有所有裝置都刪掉才會真正消失。還原備份或多台機器同步時建議使用。',
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
'cloudSync.strategy.preferCloud': '雲端優先',
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
@@ -815,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.history.download': '下載',
'cloudSync.history.resolved': '已解決',
'cloudSync.history.error': '錯誤',
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
'cloudSync.localBackups.clear': '清空全部備份',
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
@@ -909,6 +942,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.conflict.entity.customGroups': '群組',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
'cloudSync.conflict.entity.groupConfigs': '群組設定',
'cloudSync.conflict.entity.settings': '設定',
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
+87 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { type TestContext } from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
});
}
}
function installBackupLocks(t: TestContext) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
let readers = 0;
let writer: Promise<void> | null = null;
const locks = {
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
if (options.mode === 'shared') {
if (writer) await writer;
readers += 1;
try { return await callback({}); } finally { readers -= 1; }
}
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
if (readers || writer) return callback(null);
let release!: () => void;
writer = new Promise<void>((resolve) => { release = resolve; });
try { return await callback({}); } finally { writer = null; release(); }
},
};
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
else Reflect.deleteProperty(globalThis, 'navigator');
});
}
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
}
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
await active;
try {
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
} finally {
finish();
await restoring;
}
await clearLocalVaultBackups();
assert.equal(clears, 1);
});
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
installClearBridge(t, async () => { entered(); await gate; });
const clearing = clearLocalVaultBackups();
await active;
let applied = false;
const restoring = withRestoreBarrier(async () => { applied = true; });
await Promise.resolve();
assert.equal(applied, false);
finish();
await Promise.all([clearing, restoring]);
assert.equal(applied, true);
});
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
});
+28
View File
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
export class LocalVaultBackupBusyError extends Error {
constructor() {
super('Finish restoring or recovering the vault before clearing backups.');
this.name = 'LocalVaultBackupBusyError';
}
}
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function clearLocalVaultBackups(): Promise<void> {
const bridge = netcattyBridge.get();
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
await bridge.clearVaultBackups!();
});
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
// Shared locks allow existing nested restore helpers. Cleanup never queues
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
return locks
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
: runWithRestoreBarrier(task, holdMs);
}
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
+155
View File
@@ -0,0 +1,155 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import type { MonitorPanel } from "../domain/monitorPanels";
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
const {
buildCloudSyncPayload,
buildLocalVaultPayload,
applySyncPayload,
applyLocalVaultPayload,
} = await import("./syncPayload");
const {
writeMonitorPanels,
readMonitorPanels,
saveMonitorPanel,
deleteMonitorPanel,
} = await import("./state/monitorPanelsStorage");
const panel: MonitorPanel = {
id: "p",
name: "Fleet",
description: "",
hostIds: ["h1", "h2"],
view: "charts",
interval: 10,
range: 15,
sort: "manual",
};
const vault = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [],
};
const opts = { collectPluginSidecars: () => undefined };
const importers = { importVaultData: () => {} };
test("real builders and importers round-trip panel configuration through cloud and backups without runtime state", async () => {
store.clear();
writeMonitorPanels([
{
...panel,
stats: { cpu: 42 },
running: true,
points: [1],
error: "never-save",
},
]);
const cloud = await buildCloudSyncPayload(vault, [], opts);
const backup = buildLocalVaultPayload(vault);
assert.deepEqual(cloud.monitorPanels, [panel]);
assert.deepEqual(backup.monitorPanels, [panel]);
assert.ok(!JSON.stringify(cloud).includes("never-save"));
writeMonitorPanels([]);
await applySyncPayload(cloud, importers);
assert.deepEqual(readMonitorPanels(), [panel]);
delete backup.aiSessions;
writeMonitorPanels([]);
await applyLocalVaultPayload(backup, importers);
assert.deepEqual(readMonitorPanels(), [panel]);
const renamed = {
...panel,
name: "Changed",
hostIds: ["h2"],
view: "list" as const,
};
await applySyncPayload({ ...cloud, monitorPanels: [renamed] }, importers);
assert.deepEqual(readMonitorPanels(), [renamed]);
const legacy = { ...cloud };
delete legacy.monitorPanels;
await applySyncPayload(legacy, importers);
assert.deepEqual(readMonitorPanels(), [renamed]);
await applySyncPayload({ ...cloud, monitorPanels: [] }, importers);
assert.deepEqual(readMonitorPanels(), []);
});
test("serialized window mutations rebase stale fields and do not resurrect a deletion", async () => {
store.clear();
writeMonitorPanels([panel]);
await Promise.all([
saveMonitorPanel(panel, { ...panel, name: "Rename" }),
saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" }),
]);
assert.deepEqual(readMonitorPanels(), [
{ ...panel, name: "Rename", hostIds: ["h3"], view: "list" },
]);
await deleteMonitorPanel("p");
await assert.rejects(
saveMonitorPanel(panel, { ...panel, name: "Stale" }),
/deleted/,
);
assert.deepEqual(readMonitorPanels(), []);
});
test("cloud apply preserves panel edits committed after preparation", async () => {
store.clear();
writeMonitorPanels([panel]);
const { prepareSyncPayloadApply } = await import("./syncPayload");
const prepared = await prepareSyncPayloadApply(
{
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 1,
monitorPanels: [{ ...panel, name: "From cloud" }],
},
importers,
);
await saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" });
await prepared();
assert.deepEqual(readMonitorPanels(), [
{ ...panel, name: "From cloud", hostIds: ["h3"], view: "list" },
]);
});
test("cloud and backup imports preserve both independently created same-name panels", async () => {
store.clear();
const original = { ...panel, id: "a", name: "Fleet" };
const other = { ...panel, id: "b", name: "FLEET" };
writeMonitorPanels([original]);
await applySyncPayload(
{
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 1,
monitorPanels: [other, original],
},
importers,
);
assert.deepEqual(
readMonitorPanels().map((p) => [p.id, p.name]),
[
["a", "Fleet"],
["b", "FLEET (2)"],
],
);
const backup = buildLocalVaultPayload(vault);
delete backup.aiSessions;
writeMonitorPanels([]);
await applyLocalVaultPayload(backup, importers);
assert.equal(readMonitorPanels().length, 2);
assert.equal(
new Set(readMonitorPanels().map((p) => p.name.toLowerCase())).size,
2,
);
});
+8 -4
View File
@@ -2,6 +2,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
@@ -47,12 +48,10 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<void> {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
}
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
}
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
@@ -471,6 +470,11 @@ export function serializeSessionsForStorage(
* could not be persisted even after escalation (it stays memory-only).
*/
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
}
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
@@ -0,0 +1,328 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
type StorageListener = (event: StorageEventLike) => void;
type DeferredRead = { resolve: (value: string) => void };
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
function installLocalStorage(): Map<string, string> {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
},
configurable: true,
});
return store;
}
/**
* The store registers its cross-window `storage` listener at module load, so
* the DOM globals must exist before the dynamic imports below.
*/
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
const listeners = new Set<StorageListener>();
Object.defineProperty(globalThis, "window", {
value: {
addEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.add(listener);
},
removeEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.delete(listener);
},
netcatty: undefined as unknown,
},
configurable: true,
});
return (key: string, newValue: string | null, oldValue: string | null = null) => {
for (const listener of listeners) listener({ key, newValue, oldValue });
};
}
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const storage = installLocalStorage();
const dispatchStorageEvent = installWindow();
const {
clearHistory,
flushCommandHistoryStore,
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
queryHistory,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import(
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
);
function readPersistedCommands(): string[] {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
return parsed.entries.map((entry) => entry.command);
}
function readPersistedGeneration(): number {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return 0;
const parsed = JSON.parse(raw) as { clearGeneration?: number };
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
}
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
const composeBar = await import("./composeBarHistoryStore.ts");
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
// No autocomplete query has loaded this renderer's command-history store.
const recorded = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: 3,
});
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
});
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
clearHistory();
recordCommand("old-command", "host-1");
flushCommandHistoryStore();
recordCommand("pending-before-clear", "host-1");
// localStorage is already updated in the peer, but this renderer has not yet
// processed its queued storage event when the pending save runs.
const generation = readPersistedGeneration() + 1;
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
entries: [], version: 1, clearGeneration: generation,
}));
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), []);
assert.equal(readPersistedGeneration(), generation);
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
recordCommand("new-after-clear", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
});
test("an ordinary peer write does not cancel a locally pending command", async () => {
clearHistory();
recordCommand("local-pending-command", "host-1");
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
const ordinary = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
await new Promise((resolve) => setTimeout(resolve, 600));
assert.ok(readPersistedCommands().includes("local-pending-command"));
});
test("an external clear invalidates the cached history and cannot be resurrected", () => {
storage.clear();
clearHistory();
recordCommand("stale-command --flag", "host-1");
flushCommandHistoryStore();
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
// Simulate another renderer (main window or a detached terminal popup)
// clearing the shared localStorage entry.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
// The stale module-local cache must be dropped immediately.
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
// A later recordCommand must persist only the new command, never the stale
// snapshot it still held in memory.
recordCommand("fresh-command", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
});
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
storage.clear();
clearHistory();
recordCommand("pending-command", "host-1");
// No flush: the debounced write is still scheduled.
clearHistory();
assert.deepEqual(readPersistedCommands(), []);
// Wait past the debounce window to prove the cancelled write cannot restore
// the entries.
await new Promise((resolve) => setTimeout(resolve, 600));
assert.deepEqual(readPersistedCommands(), []);
});
test("a local clear bumps the generation and notifies subscribers", () => {
storage.clear();
clearHistory();
const before = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
recordCommand("notify-local", "host-1");
clearHistory();
assert.equal(getCommandHistoryClearGeneration(), before + 1);
assert.equal(readPersistedGeneration(), before + 1);
assert.equal(notified, 1);
unsubscribe();
clearHistory();
assert.equal(notified, 1);
});
test("an external clear notifies subscribers, while an external record does not", () => {
storage.clear();
clearHistory();
const baseline = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
// A normal record from another renderer keeps the generation: no clear event.
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
assert.equal(notified, 0);
// A clear from another renderer bumps the generation: subscribers fire.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.equal(notified, 1);
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
unsubscribe();
});
test("a histfile import that started before a clear is discarded", async () => {
storage.clear();
clearHistory();
const hostId = `local-seed-clear-${Date.now()}`;
const deferredReads: DeferredRead[] = [];
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: () =>
new Promise<string>((resolve) => {
deferredReads.push({ resolve });
}),
};
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
// Let the seeder resolve getHomeDir and reach the histfile reads.
await waitFor(() => deferredReads.length === 4);
// The user clears history while the bridge reads are still pending.
clearHistory();
for (const deferred of deferredReads) {
deferred.resolve(": 1700000000:0;echo seeded\n");
}
assert.equal(await seedPromise, 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
// The host is marked seeded so a later open cannot re-import the deleted
// commands.
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
storage.clear();
assert.equal(hasCommandHistoryBeenCleared(), false);
markCommandHistoryCleared();
assert.equal(hasCommandHistoryBeenCleared(), true);
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
});
test("a first-time histfile import is skipped once history has been cleared", async () => {
storage.clear();
clearHistory();
markCommandHistoryCleared();
const hostId = `local-seed-tombstone-${Date.now()}`;
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
};
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("an external clear also drops in-memory compose-bar recall", async () => {
const composeBar = await import("./composeBarHistoryStore.ts");
storage.clear();
clearHistory();
composeBar.pruneComposeBarHistory([]);
composeBar.recordComposeBarHistory("session-1", "echo compose");
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
let notified = 0;
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
// Simulate another renderer clearing command history. The compose-bar store
// follows the same clear event, so a detached popup drops its recall too.
const cleared = JSON.stringify({
entries: [],
version: 1,
clearGeneration: getCommandHistoryClearGeneration() + 1,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
assert.equal(notified, 1);
unsubscribe();
});
@@ -2,11 +2,19 @@
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*
* Lives in application state rather than the UI layer so the vault hook and the
* terminal UI depend on one lower-layer API, and so every renderer (main window
* and detached terminal popups) shares the same clear semantics.
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
const STORAGE_KEY = "netcatty:commandHistory";
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
@@ -26,9 +34,21 @@ export interface HistoryEntry {
interface HistoryStore {
entries: HistoryEntry[];
version: number;
/**
* Monotonic counter bumped by every global clear. Other renderers compare it
* to detect a clear performed elsewhere, and in-flight histfile imports use
* it to discard work that started before the clear.
*/
clearGeneration?: number;
}
let cachedStore: HistoryStore | null = null;
/** Last clear generation this renderer has observed. */
let knownClearGeneration: number | null = null;
function readClearGeneration(store: HistoryStore): number {
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
}
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
@@ -36,18 +56,29 @@ function loadStore(): HistoryStore {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
knownClearGeneration = readClearGeneration(parsed);
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
knownClearGeneration = 0;
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
@@ -67,6 +98,13 @@ function saveStore(store: HistoryStore): void {
}, 500);
}
function cancelPendingSave(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
/**
* Flush any pending debounced history write immediately.
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
@@ -75,13 +113,112 @@ function saveStore(store: HistoryStore): void {
*/
export function flushCommandHistoryStore(): boolean {
if (!cachedStore) return true;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
cancelPendingSave();
return persistStoreNow(cachedStore);
}
/**
* React to a write performed by another renderer (main window or a detached
* terminal popup).
*
* A bumped clear generation drops the cache and pending save, then notifies
* live autocomplete controllers. Ordinary peer writes must not cancel a local
* command that is still waiting for its debounced save.
*/
function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
cachedStore = null;
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!saveTimer) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
}
}
// The native `storage` event fires in *other* same-origin windows (main window
// and detached terminal popups) when a key changes there.
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
});
}
/**
* Current clear generation. Callers capture this before async work and compare
* afterwards to detect a clear that happened while they were waiting.
*/
export function getCommandHistoryClearGeneration(): number {
if (knownClearGeneration === null) loadStore();
return knownClearGeneration ?? 0;
}
/**
* Record that the user explicitly cleared all command history.
*
* Histfile seeding honors this tombstone: a clear is a user decision to delete
* locally recorded history, so a later first-time import must not restore the
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
* clear that happened before the import started).
*/
export function markCommandHistoryCleared(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
}
/** True once the user has cleared all command history at least once. */
export function hasCommandHistoryBeenCleared(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
}
type CommandHistoryClearedListener = () => void;
const clearedListeners = new Set<CommandHistoryClearedListener>();
/**
* Subscribe to command-history clears, including clears performed in another
* renderer. Live autocomplete controllers use this to drop visible suggestions
* and ghost text and abort in-flight completions, so a stale row cannot be
* accepted and recorded after the history was cleared.
*/
export function subscribeCommandHistoryCleared(
listener: CommandHistoryClearedListener,
): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
function notifyCommandHistoryCleared(): void {
for (const listener of [...clearedListeners]) listener();
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
@@ -149,10 +286,7 @@ export function removeCommandHistoryEntry(command: string, hostId: string): bool
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
cancelPendingSave();
return persistStoreNow(store);
}
@@ -427,6 +561,8 @@ function fuzzyScore(query: string, target: string): number {
/**
* Clear all history for a specific host, or all history if no hostId given.
* A global clear also bumps the shared clear generation so other renderers and
* in-flight histfile imports can detect it.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
@@ -434,6 +570,12 @@ export function clearHistory(hostId?: string): void {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
store.clearGeneration = readClearGeneration(store) + 1;
}
saveStore(store);
// Destructive clears must be durable: cancel any pending debounced write and
// persist synchronously so a later stale flush cannot restore the entries.
cancelPendingSave();
persistStoreNow(store);
knownClearGeneration = readClearGeneration(store);
if (!hostId) notifyCommandHistoryCleared();
}
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } from './composeBarHistoryStore';
test('compose history is isolated per terminal and removed when its session closes', () => {
pruneComposeBarHistory([]);
@@ -40,3 +40,24 @@ test('failed submissions unblock later successes in submission order', async ()
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
pruneComposeBarHistory([]);
});
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'echo a');
recordComposeBarHistory('b', 'echo b');
let notified = 0;
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
clearComposeBarHistory();
assert.deepEqual(getComposeBarHistory('a'), []);
assert.deepEqual(getComposeBarHistory('b'), []);
assert.equal(notified, 1);
unsubscribe();
clearComposeBarHistory();
assert.equal(notified, 1);
});
@@ -1,10 +1,14 @@
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
// Session-only memory: survives closing the bar and moving a terminal into a
// workspace, but is discarded when that terminal session is removed.
type PendingSend = { settled: boolean; command?: string };
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
type Listener = () => void;
const clearedListeners = new Set<Listener>();
export function getComposeBarHistory(sessionId: string): readonly string[] {
return histories.get(sessionId)?.entries ?? [];
}
@@ -44,3 +48,36 @@ export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
if (!active.has(id)) histories.delete(id);
}
}
/**
* Subscribe to full compose-bar history clears. A live compose bar uses this to
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
*/
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
/**
* Clear the in-memory compose-bar history for every session.
*
* This store is session-only (never persisted), so the "clear all command
* history" action must clear it explicitly. Otherwise a command sent through
* the compose bar stays recallable, and resending it would re-record it into
* shell history and autocomplete after the user deleted it.
*/
export function clearComposeBarHistory(): void {
histories.clear();
// Always notify: a live compose bar can still hold a cached walk even when
// the map is already empty (for example after its session was pruned).
for (const listener of [...clearedListeners]) listener();
}
// The compose bar holds the most recent commands sent through it, so clearing
// command history must drop it too. Subscribing to the command-history clear
// event covers every renderer: a detached terminal popup renders its own
// TerminalComposeBar and learns about a main-window clear through the shared
// clear generation, while the main window is notified synchronously.
subscribeCommandHistoryCleared(clearComposeBarHistory);
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
test('detached terminal requests reach the main window instead of being dropped', async () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const forwarded: HostBrowserPanelRequest[] = [];
const delivered: HostBrowserPanelRequest[] = [];
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
let removed = false;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
nativeListener = listener;
return () => { removed = true; nativeListener = undefined; };
},
} } });
let unsubscribe: (() => void) | undefined;
try {
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
requestHostBrowserPanel(request);
await Promise.resolve();
assert.deepEqual(forwarded, [request]);
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
assert.ok(nativeListener);
nativeListener(request);
assert.deepEqual(delivered, [request]);
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
unsubscribe(); unsubscribe = undefined;
assert.equal(removed, true);
} finally {
unsubscribe?.();
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
}
});
@@ -0,0 +1,35 @@
import type { Host } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
type Listener = (request: HostBrowserPanelRequest) => void;
const listeners = new Set<Listener>();
let pending: HostBrowserPanelRequest | null = null;
let unsubscribeNative: (() => void) | undefined;
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
if (!listeners.size) {
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
if (forward) {
void forward(request).catch(() => { pending = request; });
} else pending = request;
return;
}
for (const listener of listeners) listener(request);
}
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
listeners.add(listener);
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
for (const current of listeners) current(request);
});
if (pending) {
const request = pending;
pending = null;
listener(request);
}
return () => {
listeners.delete(listener);
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
};
}
+98
View File
@@ -0,0 +1,98 @@
import { mergeSyncPayloads } from "../../domain/syncMerge";
import { useSyncExternalStore } from "react";
import {
normalizeMonitorPanels,
rebaseMonitorPanel,
type MonitorPanel,
} from "../../domain/monitorPanels";
import { STORAGE_KEY_MONITOR_PANELS } from "../../infrastructure/config/storageKeys";
import {
localStorageAdapter,
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
} from "../../infrastructure/persistence/localStorageAdapter";
import { withVaultImportLock } from "./vaultManagedImportLock";
export function readMonitorPanels(): MonitorPanel[] {
return normalizeMonitorPanels(
localStorageAdapter.read(STORAGE_KEY_MONITOR_PANELS),
);
}
export function writeMonitorPanels(value: unknown): void {
if (
!localStorageAdapter.write(
STORAGE_KEY_MONITOR_PANELS,
normalizeMonitorPanels(value),
)
)
throw new Error("monitor.saveFailed");
}
export async function saveMonitorPanel(
base: MonitorPanel | null,
next: MonitorPanel,
): Promise<void> {
return withVaultImportLock("vault", async () =>
writeMonitorPanels(rebaseMonitorPanel(readMonitorPanels(), base, next)),
);
}
export async function deleteMonitorPanel(id: string): Promise<void> {
return withVaultImportLock("vault", async () =>
writeMonitorPanels(readMonitorPanels().filter((p) => p.id !== id)),
);
}
/** Rebase a prepared cloud snapshot against edits committed while it was applying. */
export async function applyMonitorPanels(
value: unknown,
base: MonitorPanel[],
replace = false,
): Promise<void> {
return withVaultImportLock("vault", async () => {
const remote = normalizeMonitorPanels(value);
if (replace) {
writeMonitorPanels(remote);
return;
}
const payload = (panels: MonitorPanel[]) => ({
hosts: [],
keys: [],
snippets: [],
customGroups: [],
syncedAt: 0,
monitorPanels: panels,
});
writeMonitorPanels(
mergeSyncPayloads(
payload(base),
payload(readMonitorPanels()),
payload(remote),
).payload.monitorPanels ?? [],
);
});
}
let cachedRaw: string | null | undefined;
let cached: MonitorPanel[] = [];
function getSnapshot() {
const raw = localStorageAdapter.readString(STORAGE_KEY_MONITOR_PANELS);
if (raw !== cachedRaw) {
cachedRaw = raw;
cached = readMonitorPanels();
}
return cached;
}
function subscribe(listener: () => void) {
const handler = (event: Event) => {
const key =
event instanceof StorageEvent
? event.key
: (event as CustomEvent<{ key: string }>).detail?.key;
if (!key || key === STORAGE_KEY_MONITOR_PANELS) listener();
};
window.addEventListener("storage", handler);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
};
}
export function useMonitorPanels(): MonitorPanel[] {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
publishMonitorSnapshot,
getMonitorHistoryCursor,
} from "./monitorRuntimeStore";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "gpu-cursor-test",
epoch: 1,
takenAt: 1000,
running: ["panel"],
count: 1,
hostCount: 1,
activeSessions: [],
startedAt: { panel: 0 },
hosts: {
host: {
historyId: "history",
status: "live",
at: 1000,
statsAt: 1001,
points: [
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
],
},
},
};
publishMonitorSnapshot(snapshot);
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 1001,
historyId: "history",
});
publishMonitorSnapshot({
...snapshot,
historyMode: "append",
takenAt: 2000,
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
});
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 2001,
historyId: "history",
});
});
+56
View File
@@ -0,0 +1,56 @@
import { useSyncExternalStore } from "react";
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
const empty: MonitorSnapshot = {
historyMode: "replace",
running: [],
count: 0,
hostCount: 0,
epoch: 0,
runtimeId: "",
takenAt: 0,
activeSessions: [],
startedAt: {},
hosts: {},
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function publishMonitorSnapshot(next: MonitorSnapshot) {
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
for (const l of listeners) l();
}
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
return {
runtimeId: snapshot.runtimeId,
epoch: snapshot.epoch,
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
publishMonitorSnapshot({ ...empty, unavailable: true });
}
export function useMonitorSnapshot() {
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
() => snapshot,
() => empty,
);
}
export const monitorActions: {
start: (id: string) => void;
stop: (id: string) => void;
stopAll: () => void;
retry: (
id: string,
credentials?: { username: string; password: string },
) => void;
} = {
start: () => {},
stop: () => {},
stopAll: () => {},
retry: () => {},
};
@@ -0,0 +1,244 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHostTerminalSession } from "./sessionFactories.ts";
import { createLocalTerminalSession } from "./sessionFactories.ts";
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import type { Host } from "../../domain/models";
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
id: "local-vault-1",
label: "Project A CMD",
hostname: "localhost",
username: "",
group: "Projects/A",
tags: ["local"],
os: "linux",
protocol: "local",
createdAt: 0,
...overrides,
});
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
const host = localVaultHost({
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/work"],
localShellName: "Ubuntu (WSL)",
localShellIcon: "ubuntu",
localStartDir: "/work",
});
const session = createHostTerminalSession("session-1", host);
assert.equal(session.protocol, "local");
assert.equal(session.hostId, "local-vault-1");
assert.equal(session.localShell, "wsl-ubuntu");
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
assert.equal(session.localShellName, "Ubuntu (WSL)");
assert.equal(session.localShellIcon, "ubuntu");
assert.equal(session.localStartDir, "/work");
});
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
const host: Host = {
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
} as Host;
const session = createHostTerminalSession("session-2", host);
assert.equal(session.protocol, "ssh");
assert.equal(session.localShell, undefined);
assert.equal(session.localShellName, undefined);
assert.equal(session.shellType, undefined);
});
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
assert.equal(cmd.shellType, "cmd");
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
assert.equal(pwsh.shellType, "powershell");
// Discovered shell IDs persisted by the local-shell editor are not
// executable basenames, but they still resolve to POSIX shells at spawn
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
assert.equal(wslId.shellType, "posix");
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
assert.equal(gitBash.shellType, "posix");
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
assert.equal(cygwin.shellType, "posix");
// Unix discovery emits disambiguated path-slug IDs when multiple
// installations share a basename (e.g. /bin/bash vs
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
// the session must classify as that command's shell type, not "unknown".
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
assert.equal(slugBash.shellType, "posix");
const slugHomebrewBash = createHostTerminalSession(
"session-slug-homebrew",
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
);
assert.equal(slugHomebrewBash.shellType, "posix");
const customPowershellPath = createHostTerminalSession(
"session-pwsh-path",
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
);
assert.equal(customPowershellPath.shellType, "powershell");
// Default-shell hosts (empty localShell) launch the machine's OS default
// shell (the backend's own detection), so classification must use the
// runtime platform — not the saved host.os, which may describe a machine
// the host was synced/imported from. Node 22's navigator.userAgent is
// always "Node.js/<version>", so this test environment always classifies
// as POSIX regardless of the CI runner's real OS.
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
assert.equal(windowsSyncedDefault.shellType, "posix");
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
assert.equal(posixDefault.shellType, "posix");
const unknownCustom = createHostTerminalSession(
"session-unknown",
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
);
assert.equal(unknownCustom.shellType, "unknown");
});
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
assert.equal(session.hostId, "local-terminal");
assert.equal(session.localShellName, "CMD");
});
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
// Legacy vault hosts can predate localShellArgs, so the field may be
// absent on the Host; the session snapshot must still distinguish
// "launched with no args" ([]) from "no metadata" (undefined).
const host = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-noargs", host);
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
assert.deepEqual(session.localShellArgs, []);
});
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
const launchTimeHost = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-1", launchTimeHost);
// The saved host was later edited to a shell with args; the snapshot taken
// at launch time must win so the clone relaunches CMD, not WSL.
const editedHost: Host = {
...launchTimeHost,
label: "Edited WSL",
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/elsewhere"],
localShellName: "Ubuntu (WSL)",
localStartDir: "/elsewhere",
};
const resolved = resolveTerminalSessionHost({
session,
hosts: [editedHost],
groupConfigs: [],
proxyProfiles: [],
localOs: "windows",
});
assert.equal(resolved.localShell, "cmd");
assert.deepEqual(resolved.localShellArgs, []);
assert.equal(resolved.localShellName, "Command Prompt");
});
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
// The saved host has no directory of its own, so the starter launches in
// Settings > Terminal's localStartDir. That effective directory has to be
// recorded now; overlay falls back to the host or the current setting when
// the session field is missing.
const saved = localVaultHost({
localShell: "bash",
localShellName: "Bash",
localStartDir: "",
});
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
const session = createHostTerminalSession("session-inherit", effective);
assert.equal(session.localStartDir, "/home/alice/global");
const changedSettings = {
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
};
const afterSettingsChange = resolveTerminalSessionHost({
session,
hosts: [saved],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
const afterHostEdit = resolveTerminalSessionHost({
session,
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
});
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
const saved = localVaultHost();
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
});
const session = createHostTerminalSession("session-default-shell", effective);
assert.equal(session.localShell, "/bin/bash");
assert.deepEqual(session.localShellArgs, ["-l"]);
assert.equal(session.localStartDir, "/home/alice/global");
});
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedExplicit.localStartDir, "/work");
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedSsh.localStartDir, undefined);
assert.equal(resolvedSsh, ssh);
});
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
const saved = localVaultHost({ username: "" });
const ordinary = createLocalTerminalSession("ordinary-local");
const opened = createHostTerminalSession("saved-local", saved);
assert.equal(opened.username, ordinary.username);
assert.equal(opened.username, "local");
assert.equal(saved.username, "");
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
});
+14 -1
View File
@@ -4,7 +4,7 @@ import test from "node:test";
import type { Host } from "../../domain/models";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -171,3 +171,16 @@ test("host session factories snapshot plugin connection configuration", () => {
assert.deepEqual(workspace.pluginConnection, pluginConnection);
assert.notEqual(workspace.pluginConnection, pluginConnection);
});
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
const source = createHostTerminalSession("original-bash", original);
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
assert.equal(copied.localShell, "/bin/bash");
assert.equal(copied.localShellName, "");
assert.equal(copied.localShellIcon, "");
const named = createHostTerminalSession("named-zsh", edited);
assert.equal(named.localShellName, "Zsh");
assert.equal(named.localShellIcon, "zsh");
});
+47
View File
@@ -1,4 +1,5 @@
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
@@ -18,6 +19,28 @@ export interface LocalTerminalOptions {
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
/**
* [] captures the absence of custom arguments instead of inheriting them
* from a later host edit. The starter still applies backend defaults to []
* and undefined alike. Share the array to preserve pane equality checks.
*/
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
/**
* Platform string describing the machine that is launching the session.
*
* Vault local hosts can be synced/imported from another OS, so their saved
* `host.os` describes the source machine. When the host has no explicit
* shell (`localShell` empty), `startLocalSession` spawns the current
* machine's OS default shell, so classification must fall back to the
* runtime platform — not the persisted host OS — or a Windows host opened
* on Linux would be tagged "powershell" while Bash actually runs.
*/
const runtimePlatformLike = (): string | undefined => {
if (typeof navigator === "undefined") return undefined;
return navigator.userAgent || navigator.platform || undefined;
};
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
@@ -113,6 +136,30 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
// Local-shell vault hosts carry the per-host shell selection into the
// session (the bridge resolves discovered shell ids at spawn time).
// shellType matches ordinary local-terminal creation so captured-CWD
// restore can reject POSIX injection into CMD/PowerShell sessions.
...(host.protocol === "local" ? {
username: "local",
// With an explicit shell the basename classifies it on any OS, so the
// saved host.os stays the fallback; a default-shell host launches the
// current machine's OS default, so classify against the runtime.
shellType: classifyLocalShellType(
host.localShell,
host.localShell?.trim() ? host.os : runtimePlatformLike(),
),
localShell: host.localShell ?? "",
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName ?? "",
localShellIcon: host.localShellIcon ?? "",
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
localStartDir: host.localStartDir || "~",
} : {}),
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
@@ -148,7 +148,8 @@ test("session peer windows do not run main-window startup effects", () => {
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
// Detached windows share opacity, while general settings broadcasts stay off.
assert.match(settingsStateSource, /if \(!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
@@ -7,6 +7,55 @@ import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
for (const direction of ["download", "upload"] as const) {
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
let streamStarts = 0;
const restore = installGlobals({
statSftp: async () => ({ type: "file", size: 3626, lastModified: 1758000001000 }),
statLocal: async () => null,
startStreamTransfer: async () => { streamStarts += 1; return {}; },
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = `failed-${direction}-connection-3698`;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(direction === "download" ? side : side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => { throw new Error("SFTP channel open timed out after 10000ms"); },
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const results = await ops!.startTransfer(
[{ name: "aops_dns_view_demo.sh", isDirectory: false }], "left", "right",
);
assert.equal(results[0]?.status, "failed");
});
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.status, "failed");
assert.equal(task?.error, "SFTP channel open timed out after 10000ms");
assert.ok(task?.endTime);
assert.equal(streamStarts, 0);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
type StartOptions = {
transferId: string;
sourcePath: string;
@@ -721,7 +721,6 @@ export const useSftpTransfers = ({
}
};
try {
const t0 = performance.now();
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
@@ -1137,7 +1136,6 @@ export const useSftpTransfers = ({
}
}
return "failed";
}
} finally {
// Walk registry cleanup is owned by transferRuntime.runWalk.
// Drop ephemeral pool holds opened when browse was soft-closed.
@@ -5,6 +5,8 @@ import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import { collectSessionIds } from '../../domain/workspace';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
@@ -20,6 +22,60 @@ const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('workspace browser follows focus without replacing the earlier terminal context', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const browserSlot = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'SidePanelBrowserSlot');
assert.ok(browserSlot);
const slotCode = ts.transpile(browserSlot.getText(ast), { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React });
const Slot = vm.runInNewContext(`${slotCode}; SidePanelBrowserSlot`, {
React, useRef, useSidePanelLiveSnapshotForTab: useSnapshot, resolveSystemSidebarSession, collectSessionIds,
sidePanelHiddenPanelClassName: (hidden: boolean) => hidden ? 'hidden' : 'visible',
HostBrowserSidePanel: (props: object) => React.createElement('div', { ...props, 'data-browser-context': true }),
}) as React.ComponentType<{ tabId: string; ctx: object; isVisible: boolean }>;
const sessionA = { id: 'session-a', hostId: 'host-a', protocol: 'ssh' };
const sessionB = { id: 'session-b', hostId: 'host-b', protocol: 'ssh' };
const workspace = { id: 'workspace', focusedSessionId: sessionA.id, root: {
id: 'split', type: 'split', direction: 'vertical', sizes: [0.5, 0.5], children: [
{ id: 'a', type: 'pane', sessionId: sessionA.id }, { id: 'b', type: 'pane', sessionId: sessionB.id },
],
} } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const ctx = { sessions: [sessionA, sessionB], workspaceById: new Map([['workspace', workspace]]),
sessionHostsMap: new Map([['session-a', { id: 'host-a', hostname: 'a' }], ['session-b', { id: 'host-b', hostname: 'b' }]]),
sftpAuthHostBySessionId: new Map(), effectiveHosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, t: (key: string) => key };
const publish = (session: typeof sessionA) => sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeWorkspace: workspace, focusedSessionId: session.id,
activeTerminalSessionForSystem: session as SidePanelLiveSnapshot['activeTerminalSessionForSystem'] });
let renderer: ReturnType<typeof create> | undefined;
try {
publish(sessionA);
await act(async () => { renderer = create(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: true })); });
const contexts = () => renderer!.root.findAll((node) => node.type === 'div' && node.props['data-browser-context'] === true);
const first = contexts()[0];
assert.equal(first.props.host.id, 'host-a');
await act(async () => publish(sessionB));
assert.equal(contexts().length, 2);
assert.equal(contexts()[0], first, 'the first terminal keeps the same browser mount');
assert.equal(first.props.isVisible, false);
assert.equal(contexts()[1].props.host.id, 'host-b');
assert.equal(contexts()[1].props.isVisible, true);
await act(async () => publish(sessionA));
assert.equal(first.props.isVisible, true);
assert.equal(contexts()[1].props.isVisible, false);
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
assert.ok(contexts().every((context) => !context.props.isVisible), 'hidden tabs hide every native browser context');
ctx.workspaceById.set('workspace', { ...workspace, focusedSessionId: sessionB.id,
root: { id: 'b', type: 'pane', sessionId: sessionB.id } });
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
assert.equal(contexts().length, 1, 'a detached session must leave the old workspace even while it still exists globally');
assert.equal(contexts()[0].props.host.id, 'host-b', 'the remaining workspace browser stays mounted');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
+2 -3
View File
@@ -266,9 +266,9 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
@@ -294,7 +294,6 @@ export function useSystemSettingsEffects({
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
}, [
enabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
notifySettingsChanged,
@@ -224,3 +224,31 @@ test("split and copy session clones preserve isolated plugin connection snapshot
assert.equal(split.reuseConnectionFromSessionId, undefined);
assert.equal(copied.reuseConnectionFromSessionId, undefined);
});
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
const source = session({ protocol: "local", localShell, shellType });
const copied = clone(source, { id: "clone", localShellType: "posix" });
assert.equal(copied.localShell, localShell);
assert.equal(copied.shellType, shellType);
assert.equal(clone({ ...source, shellType: undefined },
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
}
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
{ id: "clone-default", localShellType: "posix" });
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
});
}
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
for (const clone of [
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
]) {
assert.equal(clone.localShell, "");
assert.equal(clone.shellType, "powershell");
assert.equal(clone.localStartDir, "~");
}
});
+7 -1
View File
@@ -1,4 +1,5 @@
import type { TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
type SftpReuseCandidate = Pick<
TerminalSession,
@@ -44,7 +45,12 @@ function getClonedShellType(
session: TerminalSession,
localShellType?: TerminalSession["shellType"],
): TerminalSession["shellType"] {
return session.protocol === "local" ? localShellType : session.shellType;
if (session.protocol !== "local") return session.shellType;
// Clones retain an explicit per-session executable, so its shell type must
// survive a different global default (e.g. saved CMD with WSL in Settings).
return session.localShell !== undefined
? (session.shellType ?? classifyLocalShellType(session.localShell))
: localShellType;
}
function createTerminalSessionClone(
@@ -19,6 +19,7 @@ export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
'sftp',
'browser',
'scripts',
'history',
'theme',
+11
View File
@@ -18,6 +18,7 @@ import type {
} from '../../infrastructure/ai/types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { stopAgentTurn } from '../../infrastructure/ai/harness/agentStop';
import type {
TurnSteerInput,
TurnSteerResult,
@@ -46,6 +47,16 @@ export function isAIChatSessionStreaming(sessionId: string | null | undefined):
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
/** Use the same stop entry and controllers as the UI before replacing history. */
export async function stopAIChatSessionBeforeReplacement(sessionId: string): Promise<void> {
const controller = sharedAbortControllers.get(sessionId);
if (controller || sharedStreamingSessionIds.has(sessionId)) {
await stopAgentTurn({ chatSessionId: sessionId, abortController: controller, bridge: getNetcattyBridge() });
}
await getAgentRuntime().waitForActiveTurn(sessionId);
if (controller && sharedAbortControllers.get(sessionId) === controller) sharedAbortControllers.delete(sessionId);
}
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Fork source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function ForkHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(ForkHarness, { capture }));
});
return { dom, container, root, capture };
}
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'running-command' },
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.externalSessionId = 'original-provider-thread';
source.messages[1].content = `Preview handleId=${handle.id}`;
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const { dom, container, root, capture } = await setupAiState([source]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
assert.ok(branch);
assert.notEqual(branch!.id, source.id);
assert.equal(branch!.externalSessionId, undefined);
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
assert.match(branch!.messages[1].content, /Preview/);
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
runtime.getSessionStateStore().clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
test('fork rejects an incomplete boundary without adding a chat', async () => {
const source = structuredClone(SOURCE_SESSION);
source.messages[1].executionStatus = 'failed';
const { dom, container, root, capture } = await setupAiState([source]);
let result: unknown;
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
assert.equal(result, null);
assert.equal(capture.sessions.length, 1);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove(); dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
});
}
+95
View File
@@ -55,6 +55,7 @@ import {
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import { planSessionFork } from '../../domain/aiSessionFork';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -86,6 +87,11 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -675,6 +681,57 @@ export function useAIState() {
};
}, [persistSessions]);
// "Fork from here": copy the conversation up to a completed assistant
// response into a new session and make it the scope's active chat. The
// original session stays untouched in history. Pure boundary validation and
// plan building live in domain/aiSessionFork; the fork has no original
// external session id or pending-turn state. Per-message replay metadata is
// preserved; output handles and running jobs remain owned by the source.
// Null is returned when the boundary is not forkable.
// `viewingScope` is the panel scope the fork was requested from; the branch
// is created there so the fork is active and visible in the very scope that
// is viewing it, even when the source session carries a stale scope (e.g. a
// terminal chat resumed in a reconnected terminal or a merged workspace).
const forkSessionFromMessage = useCallback(async (
sessionId: string,
messageId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<AISession | null> => {
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const plan = planSessionFork(source, messageId);
if (!plan.ok) return null;
const now = Date.now();
const fork: AISession = {
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
title: plan.title,
agentId: source.agentId,
scope: {
...source.scope,
// Host membership stays with the source (the messages were produced
// there); the scope the fork belongs to is the viewing scope.
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
},
messages: plan.messages,
createdAt: now,
updatedAt: now,
};
if (plan.contextCompaction) {
fork.contextCompaction = plan.contextCompaction;
}
setSessionsRaw(prev => {
// Keep the source alongside its branch within the existing history limit.
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, fork.id);
return fork;
}, [persistSessions, setActiveSessionId]);
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
const now = Date.now();
const session: AISession = {
@@ -697,6 +754,40 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
// Like Fork, Undo belongs to the panel viewing the source, which may have
// resumed a chat from a closed terminal or inherited it after a merge.
if (viewingScope) {
branched.scope = { ...branched.scope, ...viewingScope };
}
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1275,6 +1366,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1287,6 +1379,7 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
}), [
providers,
setProviders,
@@ -1335,6 +1428,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1347,5 +1441,6 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
]);
}
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
for (const viewingScope of [
{ type: 'terminal' as const, targetId: 'terminal-b' },
{ type: 'workspace' as const, targetId: 'workspace-b' },
]) {
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
const source = {
...structuredClone(SOURCE_SESSION),
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
};
const { dom, container, root, capture } = await setupAiState([source]);
const sourceBefore = structuredClone(source);
let branchId: string | undefined;
await act(async () => {
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
});
assert.ok(branchId);
const branch = capture.sessions.find(item => item.id === branchId)!;
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
// The real history predicate must still include the selected branch after
// the source terminal no longer belongs to the current workspace.
assert.equal(getSessionScopeMatchRank(
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
}
@@ -0,0 +1,276 @@
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { MonitorPanel } from "../../domain/monitorPanels";
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
installBrowserLockFixture();
const store = new Map<string, string>();
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
const events = new EventTarget();
for (const key of [
"addEventListener",
"removeEventListener",
"dispatchEvent",
] as const) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: events[key].bind(events),
});
}
const windowFixture = Object.assign(events, {
localStorage: storage,
CustomEvent,
setInterval,
clearInterval,
});
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: windowFixture,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const { useAutoSync } = await import("./useAutoSync");
const { getCloudSyncManager } =
await import("../../infrastructure/services/CloudSyncManager");
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
await import("./monitorPanelsStorage");
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
const manager = getCloudSyncManager();
const snapshot = {
...manager.getState(),
securityState: "UNLOCKED" as const,
syncState: "IDLE" as const,
autoSyncEnabled: true,
providers: {
...manager.getState().providers,
webdav: { provider: "webdav" as const, status: "connected" as const },
},
};
const uploads: SyncPayload[] = [];
const originals = {
getState: manager.getState,
syncAllProviders: manager.syncAllProviders,
loadSyncBase: manager.loadSyncBase,
inspectProviderRemote: manager.inspectProviderRemote,
setPendingLocalSync: manager.setPendingLocalSync,
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
};
manager.getState = () => snapshot;
manager.loadSyncBase = async () => null;
manager.inspectProviderRemote = async () => ({
remoteFile: null,
payload: null,
remoteChanged: false,
});
manager.syncAllProviders = async (payload) => {
uploads.push(structuredClone(payload));
return new Map<CloudProvider, SyncResult>([
["webdav", { provider: "webdav", success: true, action: "upload" }],
]);
};
manager.setPendingLocalSync = () => {};
manager.setAvailablePluginSyncProviders = () => {};
const panel: MonitorPanel = {
id: "p",
name: "Fleet",
description: "",
hostIds: ["h1", "h2"],
view: "charts",
interval: 10,
range: 15,
sort: "manual",
};
let api: ReturnType<typeof useAutoSync> | undefined;
let renderer: ReactTestRenderer | undefined;
const apply = async () => {};
const Probe = () => {
api = useAutoSync({
hosts: [],
keys: [],
snippets: [],
customGroups: ["fixture"],
startupReady: true,
onApplyPayload: apply,
onApplyConvergentPayload: apply,
});
return null;
};
const waitForUpload = async (count: number) => {
// Flush the adapter's batched event, then observe the actual debounced
// upload. Slow CI must not be mistaken for an incomplete collection.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
if (uploads.length === count) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3500));
});
} else {
const deadline = Date.now() + 10000;
while (uploads.length < count && Date.now() < deadline)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
}
assert.equal(
uploads.length,
count,
"a panel-only change must alter the auto-sync hash and upload",
);
};
try {
writeMonitorPanels([
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
]);
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
await act(async () => {
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
});
assert.deepEqual(uploads[0].monitorPanels, [panel]);
const renamed = { ...panel, name: "Renamed" };
await act(async () => {
await saveMonitorPanel(panel, renamed);
});
await waitForUpload(2);
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
const edited = {
...renamed,
hostIds: ["h2"],
view: "list" as const,
interval: 30 as const,
};
await act(async () => {
await saveMonitorPanel(renamed, edited);
});
await waitForUpload(3);
assert.deepEqual(uploads[2].monitorPanels, [edited]);
await act(async () => {
await deleteMonitorPanel(panel.id);
});
await waitForUpload(4);
assert.deepEqual(uploads[3].monitorPanels, []);
await act(async () => {
await saveMonitorPanel(null, panel);
});
await waitForUpload(5);
assert.deepEqual(uploads[4].monitorPanels, [panel]);
assert.ok(
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
);
await act(async () => {
await saveMonitorPanel(panel, panel);
});
await waitForUpload(5);
} finally {
await act(async () => {
renderer?.unmount();
});
Object.assign(manager, originals);
}
});
test("pending-sync indication coalesces edit bursts without repeatedly collecting the vault", async () => {
const manager = getCloudSyncManager();
const snapshot = {
...manager.getState(),
securityState: "UNLOCKED" as const,
syncState: "IDLE" as const,
autoSyncEnabled: true,
providers: {
...manager.getState().providers,
webdav: { provider: "webdav" as const, status: "connected" as const },
},
};
const originals = {
getState: manager.getState,
loadSyncBase: manager.loadSyncBase,
inspectProviderRemote: manager.inspectProviderRemote,
setPendingLocalSync: manager.setPendingLocalSync,
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
};
manager.getState = () => snapshot;
manager.loadSyncBase = async () => null;
manager.inspectProviderRemote = async () => ({ remoteFile: null, payload: null, remoteChanged: false });
const pending: boolean[] = [];
manager.setPendingLocalSync = (value) => { pending.push(value); };
manager.setAvailablePluginSyncProviders = () => {};
let collects = 0;
let collectBarrier: Promise<void> | undefined;
Object.defineProperty(window, "netcatty", { configurable: true, value: {
collectPluginSyncSidecars: async () => { collects++; await collectBarrier; return { version: 1, entries: [] }; },
} });
const apply = async () => {};
const config = { hosts: [], keys: [], snippets: [], customGroups: ["fixture"], startupReady: true,
onApplyPayload: apply, onApplyConvergentPayload: apply };
function Probe({ enabled = true }: { enabled?: boolean }) { useAutoSync({ ...config, enabled }); return null; }
let renderer: ReactTestRenderer | undefined;
const wait = async (ms: number) => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); };
try {
store.clear();
writeMonitorPanels([{ id: "burst", name: "Before", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
await act(async () => { renderer = create(React.createElement(Probe)); });
await wait(500);
const initial = collects;
assert.ok(initial > 0, "startup still collects a real sync baseline");
for (let i = 0; i < 10; i++) {
await act(async () => {
writeMonitorPanels([{ id: "burst", name: `Edit ${i}`, description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
await new Promise((resolve) => setTimeout(resolve, 5));
});
}
assert.equal(collects, initial, "editing must not repeatedly collect all sync data");
await wait(350);
assert.equal(collects, initial + 1, "the final edit updates the pending indicator once");
assert.equal(pending.at(-1), true);
writeMonitorPanels([]);
await wait(10);
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
const disabled = collects;
await wait(350);
assert.equal(collects, disabled, "disabling sync cancels the pending collection");
assert.equal(pending.at(-1), false);
await act(async () => { renderer!.update(React.createElement(Probe)); });
await wait(350);
let releaseCollection!: () => void;
collectBarrier = new Promise<void>((resolve) => { releaseCollection = resolve; });
const beforeInflight = collects;
writeMonitorPanels([{ id: "burst", name: "New edit", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
await wait(10);
await wait(350);
assert.equal(collects, beforeInflight + 1, "the pending collection has started");
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
await act(async () => { releaseCollection(); });
assert.equal(pending.at(-1), false, "an old collection cannot re-enable the indicator after disabling sync");
} finally {
await act(async () => renderer?.unmount());
Reflect.deleteProperty(window, "netcatty");
Object.assign(manager, originals);
}
});
+50 -5
View File
@@ -1,3 +1,5 @@
import { readMonitorPanels } from './monitorPanelsStorage';
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
/**
* useAutoSync - Auto-sync Hook for Cloud Sync
*
@@ -21,12 +23,14 @@ import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type Syn
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
import {
isPreserveDeletionsStrategy,
resolveCloudSyncConflictAction,
type CloudSyncConflictAction,
} from '../../domain/syncStrategy';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectCloudSyncableSettings,
collectAiSessionsForSync,
getEffectivePortForwardingRulesForSync,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
@@ -38,6 +42,8 @@ import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBri
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
@@ -51,6 +57,7 @@ import {
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
import { collectHostBrowserBookmarks } from '../hostBrowserBookmarks';
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
@@ -146,6 +153,7 @@ const isRestoreInProgress = (): boolean => {
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
return JSON.stringify({
monitorPanels: payload.monitorPanels,
hosts: sanitizeHostsForSync(payload.hosts),
keys: payload.keys,
identities: payload.identities,
@@ -156,9 +164,11 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
notes: payload.notes,
noteGroups: payload.noteGroups,
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
browserBookmarks: payload.browserBookmarks,
groupConfigs: payload.groupConfigs,
settings: payload.settings,
pluginSidecars: payload.pluginSidecars,
aiSessions: payload.aiSessions,
});
};
@@ -219,6 +229,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
const [hostBrowserBookmarksVersion, setHostBrowserBookmarksVersion] = useState(0);
useEffect(() => {
const unsubscribe = netcattyBridge.get()?.onHostBrowserBookmarksChanged?.(() => {
setHostBrowserBookmarksVersion((version) => version + 1);
});
return () => { unsubscribe?.(); };
}, []);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
@@ -243,7 +260,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
if (!key) return;
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
@@ -265,6 +286,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const getSyncSnapshot = useCallback(() => {
return {
monitorPanels: readMonitorPanels(),
hosts: sanitizeHostsForSync(config.hosts),
keys: config.keys,
identities: config.identities,
@@ -299,8 +321,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
const { withPluginSyncSidecars } = await import('../syncPayload');
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
const aiSessions = collectAiSessionsForSync();
const browserBookmarks = await collectHostBrowserBookmarks();
const base: SyncPayload = {
...getSyncSnapshot(),
...(aiSessions !== undefined ? { aiSessions } : {}),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
@@ -577,7 +603,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
getDataHashRef.current = getDataHash;
}, [getDataHash]);
const refreshPendingLocalSync = useCallback(async () => {
const refreshPendingLocalSync = useCallback(async (isCurrent = () => true) => {
if (
!enabled
|| convergentSyncPaused
@@ -592,6 +618,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
try {
const currentHash = await getDataHashRef.current();
if (!isCurrent()) return;
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
@@ -781,7 +808,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
hasConflict: inspection.remoteChanged,
hasConflict: inspection.remoteChanged || isPreserveDeletionsStrategy(state.syncStrategy),
hasRemoteFile: Boolean(inspection.remoteFile),
});
@@ -850,7 +877,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// upload and wipe usable cloud passwords. Same for remote-side poison.
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed, {
preserveDeletions: isPreserveDeletionsStrategy(state.syncStrategy),
});
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
@@ -1112,6 +1141,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
hostBrowserBookmarksVersion,
pluginSidecarsVersion,
convergentSyncPaused,
syncableSettingsStorageVersion,
@@ -1119,16 +1149,31 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Reflect unsynced local edits in the top-bar cloud indicator.
useEffect(() => {
void refreshPendingLocalSync();
if (!enabled || convergentSyncPaused || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
manager.setPendingLocalSync(false);
return;
}
// Collecting the pending hash builds the same full payload as an upload.
// Let rapid edits settle before doing that work, even with auto-sync off.
let cancelled = false;
const timer = setTimeout(() => {
void refreshPendingLocalSync(() => !cancelled);
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [
refreshPendingLocalSync,
getDataHash,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
sync.isSyncing,
convergentSyncPaused,
config.settingsVersion,
enabled,
bookmarksVersion,
hostBrowserBookmarksVersion,
pluginSidecarsVersion,
syncableSettingsStorageVersion,
]);
+40
View File
@@ -28,6 +28,8 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
import {
getCloudSyncManager,
@@ -173,6 +175,9 @@ export interface CloudSyncHook {
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
/** Whether AI chat sessions are included in cloud sync payloads (opt-in). */
aiSessionsSyncEnabled: boolean;
setAiSessionsSyncEnabled: (enabled: boolean) => Promise<void>;
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
@@ -249,6 +254,25 @@ const getSnapshot = (): SyncManagerState => {
return manager.getState();
};
function readAiSessionsSyncEnabled(): boolean {
return localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
}
function subscribeAiSessionsSyncEnabled(listener: () => void): () => void {
const storage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS || event.key === null) listener();
};
const local = (event: Event) => {
if ((event as CustomEvent<{ key?: string }>).detail?.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) listener();
};
window.addEventListener('storage', storage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
return () => {
window.removeEventListener('storage', storage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
};
}
export const useCloudSync = (): CloudSyncHook => {
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -724,6 +748,19 @@ export const useCloudSync = (): CloudSyncHook => {
manager.setDeviceName(name);
}, []);
const aiSessionsSyncEnabled = useSyncExternalStore(
subscribeAiSessionsSyncEnabled, readAiSessionsSyncEnabled, readAiSessionsSyncEnabled,
);
const setAiSessionsSyncEnabled = useCallback(async (enabled: boolean) => {
const { setAiSessionsSyncPreference } = await import('../syncPayload');
await setAiSessionsSyncPreference(enabled, async () => {
const providers = Object.entries(manager.getAllProviders())
.filter(([, connection]) => isProviderReadyForSync(connection));
const remotes = await Promise.all(providers.map(([provider]) => manager.downloadFromProvider(provider as CloudProvider)));
return remotes.filter((remote) => remote !== null).map((remote) => remote.payload);
}, manager.getState().syncStrategy === 'smartMergePreserve');
}, []);
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
manager.setSyncStrategy(strategy);
}, []);
@@ -864,6 +901,7 @@ export const useCloudSync = (): CloudSyncHook => {
}
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
pluginSidecars,
aiSessions: (await import('../syncPayload')).collectAiSessionsForSync(),
});
}, [ensureUnlocked]);
@@ -956,6 +994,8 @@ export const useCloudSync = (): CloudSyncHook => {
setAutoSync,
setDeviceName,
setSyncStrategy,
aiSessionsSyncEnabled,
setAiSessionsSyncEnabled,
setConvergentSyncEnabled,
refreshConvergentSyncConfig,
+33 -1
View File
@@ -3,7 +3,11 @@ import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useComposeBarHistory } from './useComposeBarHistory';
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
import {
clearComposeBarHistory,
pruneComposeBarHistory,
recordComposeBarHistory,
} from './composeBarHistoryStore';
test('changing workspace focus resets navigation without replacing the draft', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -47,3 +51,31 @@ test('an in-progress history walk stays stable when a late send evicts the oldes
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('clearing history restores the pre-walk draft instead of leaving the recalled command', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'deleted-command');
const restoredDrafts: string[] = [];
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness() {
history = useComposeBarHistory('a', (draft) => { restoredDrafts.push(draft); });
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
// Enter a history walk: the textarea now shows the recalled command while the
// hook holds the pre-walk draft.
assert.equal(history.navigate('my unsent draft', 'up'), 'deleted-command');
clearComposeBarHistory();
// The pre-walk draft is put back, so the deleted command is no longer in the
// textarea ready for Enter.
assert.deepEqual(restoredDrafts, ['my unsent draft']);
assert.equal(history.navigate('my unsent draft', 'up'), undefined);
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+24 -2
View File
@@ -1,6 +1,10 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
import {
getComposeBarHistory,
createComposeBarHistoryRecorder,
subscribeComposeBarHistoryCleared,
} from './composeBarHistoryStore';
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
@@ -16,6 +20,24 @@ export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: s
reset();
}, [reset, restoreDraft, sessionId]);
/**
* Restore the pre-walk draft and drop the cached history walk.
*
* While the user is in an ArrowUp walk the textarea shows the recalled
* command, so resetting the cursor alone would leave a deleted command ready
* for Enter. Put the saved draft back first, exactly like the session-change
* path above.
*/
const handleHistoryCleared = useCallback(() => {
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
reset();
}, [reset, restoreDraft]);
useEffect(
() => subscribeComposeBarHistoryCleared(handleHistoryCleared),
[handleHistoryCleared],
);
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
+1
View File
@@ -39,6 +39,7 @@ export const getAppLevelActions = (): Set<string> => {
'togglePaneZoom',
'openLocal',
'openSettings',
'toggleOpacity',
]);
};
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelEvent, HostBrowserState } from '../../domain/hostBrowser';
import { useHostBrowserControls } from './useHostBrowserControls';
const state = (label: string): HostBrowserState => ({ hostId: label, label, route: 'connected', error: null, activeTabId: null, tabs: [] });
test('browser events stay scoped and a late initial read cannot overwrite a newer page', async (t) => {
const env = installDomEnvironment();
let resolveInitial: (value: HostBrowserState) => void = () => assert.fail('initial read missing');
let emit: (event: HostBrowserPanelEvent) => void = () => {};
let unsubscribed = false;
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: () => new Promise<HostBrowserState>((resolve) => { resolveInitial = resolve; }),
commandHostBrowserPanel: async () => state('command'),
listHostBrowserPanelBookmarks: async () => [],
listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: (callback: typeof emit) => { emit = callback; return () => { unsubscribed = true; }; },
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel() { controls = useHostBrowserControls('browser-a', true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel)); });
await act(async () => emit({ browserId: 'browser-b', type: 'state', state: state('wrong host') }));
assert.equal(controls.state, null);
await act(async () => emit({ browserId: 'browser-a', type: 'state', state: state('new page') }));
await act(async () => resolveInitial(state('old page')));
assert.equal(controls.state?.label, 'new page');
await act(async () => renderer.unmount());
assert.equal(unsubscribed, true);
});
test('switching browser contexts invalidates an outstanding navigation response', async (t) => {
const env = installDomEnvironment();
let finish: (value: HostBrowserState) => void = () => assert.fail('command missing');
const commands: string[] = [];
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: async (id: string) => state(id),
commandHostBrowserPanel: (id: string) => { commands.push(id); return new Promise<HostBrowserState>((resolve) => { finish = resolve; }); },
listHostBrowserPanelBookmarks: async () => [], listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: () => () => {},
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel({ id }: { id: string }) { controls = useHostBrowserControls(id, true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel, { id: 'browser-a' })); });
let pending: Promise<boolean>;
await act(async () => { pending = controls.command({ type: 'navigate', url: 'http://localhost/' }); });
await act(async () => renderer.update(React.createElement(Panel, { id: 'browser-b' })));
await act(async () => finish(state('late browser-a')));
assert.equal(await pending, false);
assert.equal(controls.state?.label, 'browser-b');
assert.deepEqual(commands, ['browser-a']);
});
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserBookmark, HostBrowserCommand, HostBrowserDownload, HostBrowserState } from '../../domain/hostBrowser';
import { hostBrowserClient, readHostBrowserIcon } from '../../infrastructure/services/hostBrowserService';
export function useHostBrowserControls(browserId: string, ready: boolean) {
const [state, setState] = useState<HostBrowserState | null>(null);
const [bookmarks, setBookmarks] = useState<HostBrowserBookmark[]>([]);
const [downloads, setDownloads] = useState<HostBrowserDownload[]>([]);
const [error, setError] = useState<string | null>(null);
const [focusAddress, setFocusAddress] = useState(0);
const [downloadStarted, setDownloadStarted] = useState(0);
const generation = useRef(0);
const client = useRef<ReturnType<typeof hostBrowserClient> | null>(null);
const report = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)), []);
const command = useCallback(async (input: HostBrowserCommand) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
setError(null);
try {
const next = await current.command(input);
if (revision !== generation.current) return false;
setState(next);
if (input.type === 'new-tab' || (input.type === 'close-tab' && !next.tabs.find((tab) => tab.id === next.activeTabId)?.url)) setFocusAddress((value) => value + 1);
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
useEffect(() => {
const revision = ++generation.current;
setState(null); setBookmarks([]); setDownloads([]); setError(null); setDownloadStarted(0);
if (!ready) return;
let current: ReturnType<typeof hostBrowserClient>;
try { current = hostBrowserClient(browserId); } catch (reason) { report(reason); return; }
client.current = current;
let stateRevision = 0;
let bookmarkRevision = 0;
let downloadRevision = 0;
const active = () => revision === generation.current;
const refreshBookmarks = async () => {
const request = ++bookmarkRevision;
try { const items = await current.bookmarks(); if (active() && request === bookmarkRevision) setBookmarks(items); }
catch (reason) { if (active()) report(reason); }
};
const off = current.subscribe((event) => {
if (!active()) return;
if (event.type === 'state') { stateRevision++; setState(event.state); }
if (event.type === 'bookmarks') void refreshBookmarks();
if (event.type === 'downloads') {
downloadRevision++; setDownloads(event.downloads);
if (event.startedId) setDownloadStarted((value) => value + 1);
}
if (event.type === 'shortcut') {
if (event.shortcut.type === 'focus-address') setFocusAddress((value) => value + 1);
else void command(event.shortcut.type === 'new-tab' ? { type: 'new-tab' } : { type: event.shortcut.type, tabId: event.shortcut.tabId });
}
});
const requestState = stateRevision;
void current.state().then((next) => { if (active() && stateRevision === requestState) setState(next); }).catch((reason) => { if (active()) report(reason); });
void refreshBookmarks();
const requestDownloads = downloadRevision;
void current.downloads().then((items) => { if (active() && requestDownloads === downloadRevision) setDownloads(items); }).catch((reason) => { if (active()) report(reason); });
return () => { generation.current = revision + 1; client.current = null; off(); };
}, [browserId, ready, command, report]);
const mutateBookmark = useCallback(async (input: Partial<BrowserBookmark> | string) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
try {
if (typeof input === 'string') await current.removeBookmark(input);
else await current.saveBookmark(input);
const items = await current.bookmarks();
if (revision === generation.current) { setBookmarks(items); setError(null); }
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
const downloadCommand = useCallback(async (type: Parameters<ReturnType<typeof hostBrowserClient>['downloadCommand']>[0]['type'], id?: string) => {
const current = client.current;
const revision = generation.current;
if (!current) return;
try {
await current.downloadCommand({ type, id });
const items = await current.downloads();
if (revision === generation.current) setDownloads(items);
} catch (reason) { if (revision === generation.current) report(reason); }
}, [report]);
const readIcon = useCallback((file: File) => readHostBrowserIcon(file), []);
return { state, bookmarks, downloads, error, command, mutateBookmark, downloadCommand, focusAddress, downloadStarted,
requestAddressFocus: () => setFocusAddress((value) => value + 1), readIcon, dismissError: () => setError(null) };
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalSettings } from '../../domain/models';
import { useHostBrowserPanel } from './useHostBrowserPanel';
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
async function fixture(t: test.TestContext) {
const env = installDomEnvironment();
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
const layouts: Layout[] = [];
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
t.mock.method(netcattyBridge, 'get', () => ({
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
openHostBrowser: async () => {},
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
closeHostBrowserPanel: async () => {},
}));
let panel: ReturnType<typeof useHostBrowserPanel>;
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
panel = useHostBrowserPanel({ browserId,
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
for (const [name, previous] of originals) {
if (previous) Object.defineProperty(globalThis, name, previous);
else Reflect.deleteProperty(globalThis, name);
}
env.cleanup();
});
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
const overlay = (html: string, x = 220, y = 110) => {
env.document.body.insertAdjacentHTML('beforeend', html);
const node = env.document.body.lastElementChild!;
for (const target of [node, ...node.children]) {
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
}
return node;
};
const tick = async () => {
await act(async () => {
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
const f = await fixture(t);
assert.equal(f.layouts.at(-1)?.visible, true);
const hint = f.overlay(tooltip);
await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
const count = f.layouts.length;
await f.tick();
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
hint.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
});
test('unrelated and closed poppers leave the browser live', async (t) => {
const f = await fixture(t);
f.overlay(tooltip, 10, 10);
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
const f = await fixture(t);
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
menu.remove(); await f.tick();
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
const menu = f.overlay(tooltip); await f.tick();
menu.remove(); await f.tick();
await act(async () => complete({ layers }));
assert.deepEqual(f.panel().snapshot, []);
});
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
const f = await fixture(t);
f.overlay(tooltip); await f.tick();
assert.deepEqual(f.panel().snapshot, layers);
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
const overlay = element.getBoundingClientRect();
return overlay.width > 0 && overlay.height > 0
&& overlay.x < rect.right && overlay.right > rect.x
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
browserId: string;
host: Host;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts: KnownHost[];
terminalSettings: TerminalSettings;
isVisible: boolean;
}
export function useHostBrowserPanel(options: BrowserPanelOptions) {
const elementRef = useRef<HTMLDivElement>(null);
const optionsRef = useRef(options);
optionsRef.current = options;
const opened = useRef(false);
const mountedBrowserId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const [readyId, setReadyId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
const { browserId, isVisible } = options;
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let previous = '';
let disposed = false;
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
};
const next = JSON.stringify(layout);
if (next !== previous) {
previous = next;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
update();
return () => {
disposed = true;
cancelAnimationFrame(frame);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
}, [browserId, isVisible]);
useEffect(() => {
if (!isVisible || opened.current) return;
opened.current = true;
setError(null);
let cancelled = false;
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
if (mountedBrowserId.current === browserId) setReadyId(browserId);
}).catch((reason: unknown) => {
opened.current = false;
if (cancelled) return;
setError(reason instanceof Error ? reason.message : String(reason));
});
return () => { cancelled = true; };
}, [attempt, browserId, isVisible]);
useEffect(() => {
mountedBrowserId.current = browserId;
return () => {
opened.current = false;
mountedBrowserId.current = null;
// StrictMode replays setup immediately. Do not destroy its pending URL
// or native browser unless the owning panel actually remains unmounted.
queueMicrotask(() => {
if (mountedBrowserId.current === browserId) return;
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
});
};
}, [browserId]);
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
retry: () => setAttempt((value) => value + 1) };
}
@@ -0,0 +1,383 @@
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 type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
for (const [field, value] of [
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
] as const) {
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
await api.update({ identities });
assert.equal(await api.find(), undefined);
}
});
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
});
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
assert.equal(await api.find(), undefined);
});
}
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
assert.equal(await api.find(), undefined);
});
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, username: 'other-serial-user' } });
assert.equal(await api.find(), undefined);
});
for (const [field, value] of [
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
] as const) {
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
const api = await harness(original, { sessions: [session] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
t.after(alreadyEdited.close);
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
});
}
test('local default shell, cwd and empty args match the renderer launch contract', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const api = await harness(original);
t.after(api.close);
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShellArgs: [] } });
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
});
for (const [label, proxy] of [
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
] as const) {
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
const original = host({ protocol: 'telnet' });
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, ...proxy } });
assert.equal(await api.find(), undefined);
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
assert.equal((await api.find())?.sessionId, 'tab');
});
}
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
const original = host({ protocol: 'telnet', group: 'prod/web' });
const groupConfigs: GroupConfig[] = [];
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
await api.update({ host: { ...original } });
assert.equal(await api.find(), undefined);
});
for (const owner of ['saved', 'global'] as const) {
for (const [field, changed] of [
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
] as const) {
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = {
terminalSettings: settings,
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
if (owner === 'global') Object.assign(settings, { [field]: changed });
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, '/bin/bash');
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const settings: { localShell?: string; localStartDir?: string } = {};
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, '');
assert.equal(launched.localStartDir, '~');
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined);
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
});
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
const context = {
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
assert.equal(launched.localShell, '/bin/bash');
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
assert.equal(defaultLaunch.localShell, '');
assert.equal(defaultLaunch.localShellArgs, undefined);
});
for (const discovered of [
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
]) {
for (const owner of ['saved', 'global'] as const) {
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, discovered.id);
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
context.discoveredShells = [discovered];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, discovered.id);
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
});
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff. Show more