Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 aba62febbd fix(ai): keep dissolved workspace chat on its surviving owner
Detach-drag focuses the dragged pane, so dissolve handoff treated the
undragged pane as preferred and reminted an inherited terminal chat onto
it. Keep that chat on its owner when the owner still survives.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 05:44:55 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

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

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

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* fix: clear keychain ref when remembering KI password change

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

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

* fix: snapshot keychain username when remembering KI password

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

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

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

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

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

* fix: snapshot keychain username when remembering KI password

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

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

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈大猫 58c6a216ac feat: save SSH input compatibility per host (#3977)
* feat: save SSH input compatibility per host

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

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

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

* docs: record final native input compatibility acceptance
2026-10-26 23:16:06 +08:00
陈大猫 eb2c4b1611 fix(terminal): bound printable ZMODEM prefix echo delay (#3966) 2026-10-26 19:16:34 +08:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

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

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

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

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

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

No release/tag.
2026-10-26 16:26:29 +08:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
Netcatty-Botandgithub-actions[bot] 0256e7471d Update Nix release metadata for v1.1.89 (#3931)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 09:45:44 +08:00
github-actions[bot]andgithub-actions[bot] a4b1009c2a chore: update star history charts [skip ci] (#3932)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-26 00:10:10 +00:00
陈大猫 54b6f85b09 fix(fonts): include SFMono Nerd Font in the terminal font picker (#3930)
Add `sfmono` to KNOWN_MONOSPACE_FONTS so glued typographic family names like "SFMono Nerd Font" appear in the Settings → Terminal font picker. Matching logic unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 17:14:21 +08:00
陈大猫 6e12c314ec fix(sftp): discard TERM channel after uncertain CLOSE 2026-10-23 17:01:54 +08:00
陈大猫 97dfab225e fix(sftp): propagate dedicated dial trust and lifecycle failures
Do not fall back to a fresh GUI login when a dedicated connection rejects.
Capture capability selection before awaiting so source cleanup cannot change
the failure policy. Preserve ordinary reusable-channel fallback and cover
both paths with real bridge regression tests.
2026-10-23 16:40:52 +08:00
陈大猫 90b6569c7a fix(sftp): inherit encoding and accepted host identities for dedicated dials
Preserve PR #3875's original author commits and scoped TERM-SSHD transfer policy.
Copy persisted filename encoding into dedicated file connections. Capture only
host identities actually accepted by the terminal route, including jump hops,
and fail closed on later unverified changes without orphan host-key prompts.
Add first-trust, changed-key, route-scope and encoding regressions.
2026-10-23 16:30:42 +08:00
陈大猫 695fd27688 feat: add terminal context menu shortcut to open SFTP (#3878) 2026-10-23 14:41:06 +08:00
jichengheng fe7dbbf6b9 fix(sftp): select dedicated terminal dials during GUI reconnect 2026-10-23 14:03:03 +08:00
陈大猫 238fe45ee4 fix: avoid repeated atlas rebuilds when switching terminal tabs (#3873)
Deduplicate terminal reveal and focus recovery while preserving changed-layout and explicit resume/fullscreen recovery. Add behavioral regressions.

Related to #3825; this rendering optimization does not establish that the original Windows/macOS issue is resolved.
2026-10-23 13:53:28 +08:00
jichengheng 8afaf60c50 fix(sftp): await sequential verification handle cleanup 2026-10-23 12:53:30 +08:00
jichengheng 88a2019518 fix(sftp): verify sequential TERM-SSHD transfers before publication 2026-10-23 12:50:59 +08:00
e780d44f97 fix(mcp): keep managed stdio fail-fast when the bridge drops (#3874)
A managed tool call that races bridge shutdown was still in flight, so
the socket error rejected it with "TCP connection lost". Managed
children do not reread discovery; report that race as not connected
and leave external in-flight wording unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 12:21:20 +08:00
陈大猫 9766f21ecf feat(monitor): shared Linux disk I/O and one-second refresh (#3870) 2026-10-23 12:19:10 +08:00
jichengheng cb27b3d01b fix(mcp): isolate SFTP dials for single-channel SSH sessions 2026-10-23 12:08:52 +08:00
陈大猫 e9cd311936 fix(mcp): reconnect external stdio bridge after Netcatty restart (#3872)
When NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE is set, rpcCall recovers on demand via ensureConnected(): re-read discovery, reset TCP buffer, connect to current port, auth/verify with refreshed token. Concurrent callers coalesce. Close/error reject in-flight RPCs without replaying. Managed SDK children keep fail-fast.

Fixes #3871
2026-10-23 11:43:15 +08:00
d6871dbca9 feat: toggle the host list with Ctrl/Cmd+Shift+B (#3868)
* feat(shortcuts): toggle the host list with Ctrl/Cmd+Shift+B

Ctrl/Cmd+B already switches broadcast mode and stays available to a focused terminal. The new shortcut reuses the existing host-tree toggle.

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

* fix(shortcuts): let the host list chord through Monaco and idle terminals

Editor focus inside Monaco can toggle the host list. The chord is consumed only when that toggle runs, matching broadcast.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 10:18:20 +08:00
850c5309b6 feat(terminal): split compose-bar send target from toolbar broadcast (#3860)
* feat(terminal): split compose-bar send target from toolbar broadcast

Toolbar broadcast still fans out keystrokes, paste, and terminal-path
snippets. The compose-bar indicator is now a toggle for whether Enter
sends to broadcast peers or only the active session. Turning the toolbar
switch on or off resets that target to follow it; later flips stay
independent.

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

* fix(terminal): keep active-only compose sends off the toolbar fan-out

The focused workspace executor omitted broadcast, so executeSnippetCommand
fell back to the toolbar switch and still wrote every peer. Pass
broadcast: false on that path.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 09:48:54 +08:00
e5bfc12a2e perf(vault): store connection-log replay blobs in IndexedDB (#3861)
* perf(vault): store connection-log replay blobs in IndexedDB

Move unsaved connection-log terminal replay out of the multi-megabyte
localStorage blob into one IndexedDB row per log id. Persist only the
changed ids, keep the newest 50, and signal other windows with a tiny
revision key.

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

* fix(vault): keep connection-log replay across boot races and IndexedDB failure

Re-read connection-log metadata after the IndexedDB load before pruning,
return the capture promise through TerminalLayer, and write the unpruned
log blob when the side store rejects the commit.

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

* fix(vault): keep unmigrated connection-log replays until IndexedDB commits

Copy legacy replay ids that are missing from IndexedDB before deleting
the old blob, and do not treat an uncommitted legacy map as the diff
baseline.

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

* fix(vault): migrate legacy replays without clearing newer IndexedDB rows

Insert missing legacy ids in one transaction instead of replaceAll, so a
replay committed after the empty read is not wiped.

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

* fix(vault): rebase the IndexedDB fallback onto the latest connection logs

When the side store rejects a close, attach this window's replay to the
connection-log blob currently on disk instead of writing the stale snapshot.

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

* fix(vault): rebase queued connection-log writes onto the latest logs

A persist task can run after another window changes the log list. Write
the rebased list instead of the snapshot captured when the task was queued.

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

* fix(vault): keep queued connection logs after a remote snapshot

A storage event can replace memory with another window's logs before the
queued write runs. Keep ids from the queued snapshot unless this snapshot
deleted them.

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

* fix(vault): restore only locally changed queued logs

A queued persist was putting back every id from the pre-queue snapshot,
which undid another window's delete of a log this recipe never touched.
Track the ids the recipe added or replaced, and reload IndexedDB during
boot when the revision key moves across the migrate await so that peer
replay is hydrated before this window marks the revision seen.

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

* fix(vault): rebase connection logs after the side-store await

A non-pruned write stored the pre-await snapshot and could replace logs
another window saved during the IndexedDB round trip. Rebase immediately
before that write, shrink the quota fallback until the newest replay fits,
and leave the revision unconsumed when IndexedDB cannot be read.

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

* fix(vault): ignore same-window connection-log echoes

The deferred localStorage notification replays this window's own metadata
write. Applying it replaced connectionLogsRef with an older blob and dropped
logs that were added or finalized while that write was still queued.

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

* fix(vault): do not restore a queued log deleted from memory

A queued add or update was written back when the id was already gone from
memory, which let another window keep that row after a later local delete.
Rebase now applies a changed id only while memory still has it.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 07:55:24 +08:00
6ab684bcd5 fix(terminal): restore home input after an attached popup closes (#3866)
Attach close preparation clears the popup session ref, then unmount
cleanup called closeSession. That emits netcatty:exit to the home
renderer and can kill the shared PTY. Skip that second close.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:36 +08:00
acecd46d57 fix(sftp): recover from abandoned SFTP channel-open poison on lossy links (#3865)
A timed-out SFTP OPEN used to pin the connection forever when its callback
never arrived. Wait up to 2s, then force-clear the gate and admit a new open.
Late channels are still closed. After TIMEOUT or PENDING, skip the fastPut
reopen and upload on the shared channel.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:29 +08:00
aee852a9ac fix(sftp): resume Retry for every channel-loss error form the bridge already knows (#3864)
canRetryFromCheckpoint and the upload prefix digest missed ECONNABORTED,
socket hang up, ERR_SFTP_CHANNEL_CLOSED, and client-socket. Those Retries
restarted at byte 0 and stranded the staged .part.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:25 +08:00
5bef721ac9 perf(sftp): skip the second promotion lstat when there is no mode to restore (#3862)
New-file uploads have a null existingMode, so restoreRemoteMode is a no-op.
Recheck the destination only after a real mode restore and keep one
pre-rename safety lstat otherwise.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:18 +08:00
4c82424adf feat(terminal): open current session logs from the toolbar (#3834)
* feat: open saved session logs from terminal toolbar

Add one-click access to the current automatic or manual session log, flush before opening, and reuse application selection with a remembered log preference. Preserve recording controls and current-session ownership across worker routing and reconnects.

Refs #3807

* fix(logging): preserve saved logs on failed reconnect and localize failures

Address both Codex review findings with generation- and identity-checked replacement cleanup and localized user-facing error handling.

* fix(logging): guard successful main-process session replacement

Add shared inactive-log cleanup to fallback start handlers with regression coverage. Registration activation follows separately.

* fix(logging): enable log lifecycle cleanup for main-process fallback

* fix(terminal): keep log cleanup and tuck open-log into More actions

Always end a raw session-log write stream on finalize, including when a
flush already disabled it. Keep immediate snapshot retries, and pause
them only while prepare is waiting so a disk error can be reported.
Show open, reveal, and open-with in the toolbar overflow menu when
logging is active, instead of a default toolbar button.

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

* fix(logging): back off failed snapshot writes and drop retired log actions

Persistent snapshot failures retry on a growing delay instead of a tight
loop, and still flush immediately when new output arrives. Hide open-log
actions after a successful reconnect retires a stopped manual log.

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

* fix(terminal): keep open-log actions after automatic logging is turned off

The current automatic log keeps recording until reconnect, so the menu
stays available for that connection and clears on the next successful
connect.

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

* fix(terminal): derive log menu availability from saved session logs

* fix(terminal): nest session log actions in one More actions submenu

Collapse Open, Show in folder, and the two open-with actions into a Session log submenu that follows the Terminal Encoding menu, and drop the remembered-application hint.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 22:15:21 +08:00
439f981c67 fix(cli): show the final server on PuTTY bastion tab titles (#3859)
* fix(cli): show the final server on PuTTY bastion tab titles

PuTTY jump launches kept the bastion as the TCP host and labeled the tab
with the opaque login string. Reuse the SecureCRT tabName path so an
embedded or leftover target address is the session title, without changing
where the session connects.

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

* fix(cli): validate PuTTY jump tab addresses with net.isIP

The display-host check accepted malformed colon strings such as a:b:c and
rejected IPv4-mapped and zone-scoped IPv6 targets. Use Node's IP parser so
only real addresses become the tab title.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 21:50:10 +08:00
3d593b206b fix(terminal): close sessions when their owning window is destroyed (#3839)
* fix(terminal): close sessions when their owning renderer is destroyed

* test(terminal): keep a shared SSH transport when a copied window closes

Closing a child window must drop only the shells that window owns.
Cover several tabs on one connection split across the main window and
the copied window, for both the in-process lifetime tracker and the
worker close path.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 20:00:38 +08:00
f95e490707 perf(hosts): reuse manual order index while flattening the tree (#3842)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 19:50:01 +08:00
DeepFal bc36f6a085 fix(browser): keep the page live beside overlay side panels (#3851)
* fix(browser): keep the page live beside overlay side panels

The embedded host browser is a native WebContentsView that draws above
the renderer, and its occlusion check only yielded to dialogs, menus and
poppers. Opening New host / Edit host from the terminal host tree left
the page drawn over the overlay host editor panel.

Overlay-layout aside panels now carry data-native-view-occluder. Native
views cannot be clipped by a parent View on macOS, so the pane shrinks
the live page to the rectangle left beside a panel that covers a
full-height or full-width edge strip; the page stays interactive while
the editor is open. Poppers are checked against that live rectangle, so
selects inside the panel no longer freeze the page, and pictures for
poppers over the page are offset to where it sits. A panel over the
middle of the pane, or one leaving less than 160px, falls back to a
picture.

* test(browser): provide the pane geometry helper to the first-link harness

The first-link acceptance loads useHostBrowserPanel through a VM that
strips imports and injects dependencies by name, so it threw
"resolveHostBrowserPaneBesidePanels is not defined" once the hook
started using the domain helper. Load domain/hostBrowser.ts and pass
the helper in.
2026-10-22 18:14:06 +08:00
f7a166c3e9 fix(vault): keep the newest connection-log replays when the side store hits quota (#3855)
* fix(vault): keep the newest connection-log replays when the side store hits quota

Once the replay side store neared the localStorage quota (~48 x 1M-char
entries) every write failed with QuotaExceededError, so the stored map
froze with the oldest sessions and newer ones never survived a restart.
On a failed write, retry without the oldest unsaved replay buffers
(dropping 1, 2, 4, ... per attempt, so retries stay O(log n)) until it
fits or none are left. Storage format, per-entry cap, 50-entry cap and
log metadata are unchanged; saved logs and other windows' ids are never
dropped.

* perf(vault): start quota retries from the newest set that last fit

* fix(vault): keep the connection-log quota hint on the vault hook

The domain helper held a process-global hint, so the same logs and map
could keep a different replay set after an earlier caller. The hint now
lives in a useVaultState ref and is passed in and returned. Drop order,
saved and unknown ids, and the size estimate are unchanged.

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

---------

Co-authored-by: Grok Bot <grokbot@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 17:42:37 +08:00
7c82ec15d0 perf(vault): skip connection-log side-store parse in non-log windows (#3853)
Settings, tray, and the terminal popup mount useVaultState but never
show connection-log replay. Skip the bulky side-store load and storage
handlers there so a main-window persist does not JSON.parse it in those
renderers. Main-window behaviour and the write path stay unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 16:41:28 +08:00
2849871111 perf(vault): align in-memory connection-log terminalData with the 50-entry persist cap (#3852)
Unsaved logs kept up to 500×1M chars of terminalData in React state while
only 50 survive a restart. After add/update/toggle, drop terminalData from
the oldest unsaved entries beyond MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES.
List metadata stays; replay beyond 50 matches restart behaviour.

When the side-store write fails (QuotaExceeded once it nears ~50 MB), keep
the same capped merged map in memory instead of accumulating every log's
replay buffer in connectionLogTerminalDataRef.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 15:47:46 +08:00
36057a97d8 perf(vault): merge the connection-log replay side store once per persist (#3848)
Each connection-log write parsed the closed-session terminal-data JSON
twice, merged it twice, and JSON.stringify-compared it twice. Pass the
already-merged map through and compare keys without serializing the
multi-MB replay buffers. Storage format and in-memory state stay the same.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:31:03 +08:00
41c66f76fa perf(terminal): skip redundant grapheme scans in the output-history tracker (#3847)
The alt-screen preview tracker segmented every print span and recomputed
the last grapheme on every non-ASCII append. Skip the boundary walk when
the remainder already fits the piece cap, and segment the open grapheme
only when a later span actually rejoins it.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:30:54 +08:00
54cedb09b2 fix(ssh): don't re-prompt host key on rekey; answer it synchronously (#3845)
ssh2 re-runs hostVerifier on every key re-exchange. When the knownHosts
snapshot for the connection doesn't contain the key (first connect with
"Add and continue", "Continue" without saving), the verifier showed the
host-key dialog again and answered asynchronously. While that answer is
pending ssh2 keeps sending channel data ahead of its NEWKEYS, and OpenSSH
drops the connection ("Bad packet length"), killing the shell, rz/sz and
SFTP on that transport. Servers or gateways with a RekeyLimit hit this
after a few MB of transfer.

Like OpenSSH, accept a rekey only if it presents the key already accepted
for this connection, and answer synchronously without prompting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 12:19:14 +08:00
陈大猫 1956513a5e perf(browser): coalesce download progress history writes (#3836)
Keep live progress notifications immediate while coalescing replaceable byte-counter persistence. Flush starts, metadata, terminal states, deletions and shutdown synchronously.
2026-10-22 10:35:37 +08:00
陈大猫 5374b981ec fix(vault): preserve group order when editing details (#3843) 2026-10-22 10:35:30 +08:00
陈大猫 864b8d46eb fix(portforward): preserve runtime owner cleanup after tunnel stop (#3838) 2026-10-22 10:35:24 +08:00
陈大猫 acebaa858c perf(vault): reuse the verified key during unlock (#3841) 2026-10-22 10:35:18 +08:00
陈大猫 e7f4a37ec7 perf(ai): skip unused snapshot degradation preparation (#3837) 2026-10-22 10:35:11 +08:00
陈大猫 eeaf941778 perf(logging): share immutable style snapshots between cells (#3835)
Copy style state on SGR changes rather than for each printable character. Preserve historical styles, cleared screens and cursor overwrites without dropping log content.
2026-10-22 10:35:05 +08:00
378 changed files with 21464 additions and 1233 deletions

No files matched your search

+15
View File
@@ -82,6 +82,8 @@ on:
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/serial-named-pipe.live.test.cjs"
- "scripts/fixtures/serial/**"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -251,6 +253,10 @@ jobs:
- name: Install deps
run: npm ci
- name: Test Windows serial pipe through native Electron
if: matrix.name == 'windows'
run: npx electron scripts/serial-named-pipe.live.test.cjs
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
@@ -324,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -625,6 +634,9 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -759,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
+13
View File
@@ -52,6 +52,11 @@ jobs:
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -91,6 +96,9 @@ jobs:
- name: Install deps
run: npm ci --ignore-scripts
- name: Exercise serial named pipe transport on Windows
run: node --test electron/bridges/serialNamedPipe.test.cjs
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
@@ -135,5 +143,10 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+3 -1
View File
@@ -181,7 +181,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
## Monitoring View
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
+215 -1
View File
@@ -7,8 +7,9 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
import type { Host, Identity } from '../types';
const baseHost: Host = {
id: 'host-1',
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
@@ -8,6 +9,11 @@ import {
handleGlobalHotkeyKeyDownImpl,
markForwardedNativeShortcutEvent,
} from './app/AppHandlers.ts';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from './state/settingsChromeStore.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -829,3 +835,201 @@ for (const scenario of [
});
}
}
for (const scheme of ['pc', 'mac'] as const) {
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
const target = new FakeHTMLElement();
const shiftedActions: string[] = [];
let shiftedPrevented = false;
let shiftedStopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
shiftedActions.push(action);
return action === 'toggleHostTree';
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { shiftedPrevented = true; },
stopPropagation: () => { shiftedStopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(shiftedActions, ['toggleHostTree']);
assert.equal(shiftedPrevented, true);
assert.equal(shiftedStopped, true);
const plainActions: string[] = [];
let plainPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
plainActions.push(action);
return undefined;
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { plainPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(plainActions, ['broadcast']);
assert.equal(plainPrevented, false);
});
}
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
const target = new FakeHTMLElement();
const actions: string[] = [];
let prevented = false;
let stopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
actions.push(action);
return false;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(actions, ['toggleHostTree']);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
const monaco = new FakeMonacoHTMLElement();
const monacoActions: string[] = [];
let monacoPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
monacoActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: monaco,
composedPath: () => [monaco],
preventDefault: () => { monacoPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(monacoActions, ['toggleHostTree']);
assert.equal(monacoPrevented, true);
const input = new FakeInputHTMLElement();
const inputActions: string[] = [];
let inputPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
inputActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: input,
composedPath: () => [input],
preventDefault: () => { inputPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(inputActions, []);
assert.equal(inputPrevented, false);
});
test('toggleHostTree uses the host list toggle only while that list can show', () => {
const previous = getSettingsChromeSnapshot();
const calls: string[] = [];
const toggleHostTree = () => { calls.push('toggle'); };
const event = {} as KeyboardEvent;
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
activeTabStore: { getActiveTabId: () => activeTabId },
editorTabs: [],
orderedTabs,
sessions: [{ id: 'session-1' }],
settings: {},
toEditorTabId: (id: string) => id,
toggleHostTree,
workspaces: [{ id: 'workspace-1' }],
});
try {
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
calls.length = 0;
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
} finally {
publishSettingsChromeSnapshot(previous);
}
});
test('host list shortcut calls the same store toggle as the toolbar button', () => {
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
});
+51 -11
View File
@@ -9,6 +9,8 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
import { isHostTreeWorkTabSurface } from './workTabSurface';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
@@ -276,6 +278,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
if (
(isFormElement || isMonacoElement)
@@ -283,6 +288,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
&& e.key !== 'Escape'
&& !isQuickSwitchHotkey
&& !isPaneZoomHotkey
&& !(isMonacoElement && isHostTreeHotkey)
&& !forwardedNativeShortcutEvents.has(e)
) {
return;
@@ -322,9 +328,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
continue;
}
// An unavailable broadcast action must leave the chord to the terminal
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
if (binding.action === 'broadcast') {
// An unavailable action must leave the chord to the terminal.
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
// when the sidebar cannot show. Dispatch once before consuming it.
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
if (executeHotkeyAction(binding.action, e) === true) {
e.preventDefault();
e.stopPropagation();
@@ -386,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -417,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
@@ -801,7 +824,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, toggleWindowOpacity, 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, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -997,6 +1020,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'toggleHostTree': {
const currentId = activeTabStore.getActiveTabId();
if (!isHostTreeWorkTabSurface({
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
activeTabId: currentId,
orderedTabs: orderedTabs ?? [],
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
}) || typeof toggleHostTree !== 'function') return false;
toggleHostTree();
return true;
}
case 'broadcast': {
// Workspace tabs keep their local broadcast state; orphan tabs share
// the global state when at least two visible orphan tabs exist.
@@ -1145,6 +1180,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1164,6 +1200,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1184,7 +1221,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -1200,7 +1237,10 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
if (matchingLog) {
updateConnectionLog(matchingLog.id, {
// The IndexedDB replay write commits asynchronously. Waiting here keeps
// a crash during session teardown from dropping the newest capture any
// more often than the old synchronous localStorage write.
await updateConnectionLog(matchingLog.id, {
endTime: Date.now(),
terminalData: data,
});
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog, Host } from '../../domain/models';
import { createLocalShellHost } from '../../domain/localShellHost';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
const connectionLogs: ConnectionLog[] = [];
let appendedHost: Host | undefined;
const getCtx = () => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
connectToHost: (candidate: Host) => {
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
appendedHost = candidate;
return 'workspace-session';
},
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
connectionLogs.push({ ...entry, id: 'workspace-log' });
},
});
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
assert.equal(appendedHost?.localShell, '/bin/zsh');
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
assert.equal(appendedHost?.localStartDir, '/tmp/work');
assert.equal(connectionLogs.length, 1);
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
assert.equal(connectionLogs[0].hostId, host.id);
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
assert.equal(connectionLogs[0].username, 'local-user');
assert.equal(connectionLogs[0].protocol, 'local');
assert.equal(connectionLogs[0].localHostname, 'local-machine');
const updates: Array<{ id: string; terminalData: string }> = [];
await handleTerminalDataCaptureImpl(() => ({
IS_DEV: false,
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
// A newer, same-host log must not steal this session's capture.
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
selectConnectionLogForTerminalDataCapture,
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
}), 'workspace-session', 'captured terminal output');
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
});
for (const protocol of ['local', 'ssh', 'serial'] as const) {
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
let logs = 0;
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
const sessionId = handleConnectToHostImpl(() => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
connectToHost: () => null,
addConnectionLog: () => { logs += 1; },
}), host);
assert.equal(sessionId, null);
assert.equal(logs, 0);
});
}
+16 -8
View File
@@ -56,6 +56,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
@@ -70,6 +71,7 @@ import {
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import {
markExternalMcpStartupReady,
readExternalMcpFocusOnHostOpen,
@@ -254,6 +256,7 @@ export function AppSideEffects() {
createLocalTerminal,
createSerialSession,
connectToHost,
appendHostToWorkspaceConfirmed,
closeSession,
closeSessions,
closeWorkspace,
@@ -713,7 +716,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -1126,6 +1129,7 @@ export function AppSideEffects() {
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleHostTree: () => terminalHostTreeStore.toggle(),
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWindowOpacity,
@@ -1456,7 +1460,7 @@ export function AppSideEffects() {
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
@@ -1474,7 +1478,9 @@ export function AppSideEffects() {
: resolveEffectiveHost;
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
connectToHost: workspaceId
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
: connectToHost,
discoveredShells,
host: connectionHost,
identities,
@@ -1485,7 +1491,7 @@ export function AppSideEffects() {
systemInfoRef,
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1495,12 +1501,14 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
if (target.error) return { ok: false as const, error: target.error };
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
// host_open always opens a visible tab, as documented.
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
const sessionId = handleConnectToHost(host, true, hidden);
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${host.id}".` };
}
@@ -1509,8 +1517,8 @@ export function AppSideEffects() {
if (readExternalMcpFocusOnHostOpen()) {
void netcattyBridge.get()?.openMainWindow?.();
}
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
}, [handleConnectToHost, workspaces]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
+5 -1
View File
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
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,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isComposeSendToAll={isComposeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
@@ -0,0 +1,43 @@
// A separate renderer thread lets the real main-process timeout run while
// window.confirm is synchronously blocked, just like Electron's native dialog.
require("tsx/cjs");
const { parentPort, workerData } = require("node:worker_threads");
const { EventEmitter } = require("node:events");
const React = require("react");
const { act, create } = require("react-test-renderer");
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
const signal = new Int32Array(workerData.signal);
let resumes = 0;
global.IS_REACT_ACT_ENVIRONMENT = true;
global.window = {
confirm() {
parentPort.postMessage({ type: "prompt" });
Atomics.wait(signal, 0, 0, 10000);
return workerData.accept;
},
};
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
sftpTransferCenterStore.pause = async () => {};
sftpTransferCenterStore.resume = async () => { resumes++; };
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
netcattyBridge.get = () => ({
onCheckDirtyEditors(callback) {
return preload.onCheckDirtyEditors((request) => {
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
});
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
});
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
return null;
}
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
void act(async () => { create(React.createElement(Probe)); })
.then(() => parentPort.postMessage({ type: "ready" }));
+4
View File
@@ -205,7 +205,11 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isComposeSendToAll: sessionActions?.isComposeSendToAll,
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
@@ -59,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -70,8 +71,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -139,6 +144,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -150,8 +156,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -163,6 +173,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
@@ -181,7 +192,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
@@ -201,7 +214,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleComposeSendTarget,
toggleGlobalBroadcast,
toggleGlobalComposeSendTarget,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
);
const initAt = vaultSource.indexOf('let cancelled = false;');
assert.notEqual(initAt, -1, 'vault init must track cancellation');
const initSlice = vaultSource.slice(initAt, initAt + 12000);
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
const initSlice = vaultSource.slice(initAt, initAt + 20000);
assert.match(
initSlice,
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
);
assert.match(
initSlice,
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
@@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { useAppStartupEffects } from './useAppStartupEffects.ts';
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
const require = createRequire(import.meta.url);
const React = require('react');
const { act, create } = require('react-test-renderer');
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
const { createPreloadApi } = require('../../electron/preload/api.cjs');
// No Electron UI, sockets, real transfers, or preferences. Only the network
// pause completion is held. Actual renderer hook, window close callback, and
// dirty-query helper run together with the actual 5000ms production deadline.
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let confirmations = 0, reports = 0, resumeCalls = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: any, releasePause: any;
const queryPromises: Promise<void>[] = [];
const pause = new Promise<void>(resolve => { releasePause = resolve; });
if (!slowPause) releasePause();
const ipcMain = new EventEmitter();
let win: any;
t.after(async () => {
releasePause(); await Promise.allSettled(queryPromises);
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
t.mock.method(netcattyBridge, 'get', () => ({
onCheckDirtyEditors(callback: any) {
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
class Window extends EventEmitter {
destroyed = false;
webContents = Object.assign(new EventEmitter(), {
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
setWindowOpenHandler() {}, openDevTools() {},
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
});
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
queryDirtyEditors(wc: any, timeout: number, options: any) {
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
return queryDirtyEditors(wc, timeout, options);
},
});
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
electronDir: '/unused/electron', registerAsMainWindow: true,
});
win.close();
assert.equal(win.destroyed, false, 'first close is correctly guarded');
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
if (!slowPause) {
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
return;
}
assert.equal(confirmations, 0, 'slow pause prevents prompt');
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
releasePause(); await Promise.allSettled(queryPromises);
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
assert.equal(win.destroyed, false);
assert.equal(resumeCalls, 1);
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
});
@@ -0,0 +1,52 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { Worker } = require("node:worker_threads");
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
for (const accept of [false, true]) {
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
const signal = new SharedArrayBuffer(4);
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
workerData: { accept, signal },
});
const ipcMain = new EventEmitter();
const events = new EventEmitter();
const replies = [];
let promptShown = false;
let closed = false;
const wc = {
isDestroyed: () => closed,
isCrashed: () => false,
send(channel, payload) { worker.postMessage({ channel, payload }); },
};
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
worker.on("message", (message) => {
if (message.type === "reply") {
replies.push(message.payload);
ipcMain.emit(message.channel, { sender: wc }, message.payload);
} else {
if (message.type === "prompt") promptShown = true;
events.emit(message.type, message);
}
});
worker.on("error", (error) => events.emit("error", error));
await once(events, "ready");
const started = Date.now();
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
if (!result) closed = true;
assert.equal(promptShown, true);
assert.equal(result, "transfers");
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
const done = once(events, "done");
Atomics.store(new Int32Array(signal), 0, 1);
Atomics.notify(new Int32Array(signal), 0);
const [completed] = await done;
assert.equal(completed.resumes, 1);
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
assert.equal(replies.at(-1).reason, "transfers");
assert.equal(closed, false);
});
}
@@ -1,11 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { TransferTask } from "../../domain/models";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 10,
transferredBytes: 2,
speed: 1,
startTime: 1,
isDirectory: false,
});
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
@@ -13,7 +30,8 @@ for (const accept of [false, true]) {
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
g.window.confirm = () => accept;
let prompts = 0;
g.window.confirm = () => { prompts++; return accept; };
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
@@ -76,12 +94,159 @@ for (const accept of [false, true]) {
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
// Dedicated resume promises can represent the whole transfer lifetime.
// A second close must still prompt while those promises remain pending.
await query();
assert.equal(prompts, 2);
assert.equal(resumeSettled, false);
assert.equal(reports.length, 4);
}
});
}
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let prompts = 0;
let resumes = 0;
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: {
confirm() { prompts++; now = 6000; return true; },
} });
t.after(async () => {
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", async () => {
if (outcome === "pause-rejected") throw new Error("pause failed");
if (outcome === "pause-expired") now = 6000;
});
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
replies.push({ dirty, reason, ...options });
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
await query({ requestId: outcome, deadline: 5000 });
assert.deepEqual(replies, [
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
{ dirty: true, reason: "transfers", requestId: outcome },
]);
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
assert.equal(resumes, 1);
});
}
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let cancelled = false;
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
cancelled = true;
await query({ requestId: "after-cancel", deadline: 11000 });
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
const replyCount = replies.length;
releasePause();
await first;
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
assert.equal(prompts, 0);
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
});
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let status: TransferTask["status"] = "transferring";
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
const retry = query({ requestId: "retry", deadline: 11000 });
status = "paused";
releasePause();
await Promise.all([first, retry]);
assert.equal(prompts, 1);
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
});
+49 -11
View File
@@ -344,12 +344,26 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(async () => {
const unsub = bridge.onCheckDirtyEditors(async (request) => {
const response = { requestId: request?.requestId };
// Another close request must not start a second pause/prompt while the
// first attempt is still pausing or awaiting a decision.
const previousAttempt = transferQuitPendingRef.current;
if (previousAttempt && Date.now() < previousAttempt.deadline) {
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
return;
}
// An expired pause may never settle. Let a retry re-check current tasks
// (including a user's cancellation), without granting the old attempt
// permission to prompt or resume over that newer decision.
let attempt: typeof previousAttempt = null;
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
@@ -374,24 +388,44 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
|| previousAttempt?.resumeTaskIds.has(task.id)
));
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
// A slow pause must never turn the main-process timeout into
// permission to quit before the user has made a decision.
hasDirty = true;
reason = "transfers";
tasksToResume = resumable;
// A retry inherits the still-unfinished rows the expired attempt
// auto-paused. Their current status may already be pausing/paused.
attempt = {
deadline: request?.deadline ?? Date.now() + 5000,
resumeTaskIds: new Set(resumable.map((task) => task.id)),
};
transferQuitPendingRef.current = attempt;
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
if (transferQuitPendingRef.current !== attempt) return;
const expired = () => request && Date.now() >= request.deadline;
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
// The native prompt can itself outlive the main-process deadline.
// An expired acceptance is not permission for a later close attempt.
if (expired()) hasDirty = true;
}
if (!hasDirty) {
reason = undefined;
tasksToResume = [];
}
} else {
// All prior work was finished/cancelled; invalidate its late pause.
transferQuitPendingRef.current = null;
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
@@ -399,8 +433,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
// Report refusal before recovery, which may itself take a long time.
// A resume promise can span the entire transfer, so do not hold the
// close gate until it settles. A later close must be able to pause again.
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
});
return unsub;
}, [enabled, t]);
+1
View File
@@ -582,6 +582,7 @@ export const enAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.refreshModelList': 'Refresh model list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+45
View File
@@ -966,6 +966,51 @@ Highlight the focused split pane:
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
+5
View File
@@ -1,4 +1,9 @@
export const enMonitorMessages = {
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
+12
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
'terminal.toolbar.sessionLog.open': 'Open',
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
@@ -68,6 +77,8 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.sendToAll': 'Send to all sessions',
'terminal.composeBar.sendToActive': 'Send to the active session only',
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
@@ -868,6 +879,7 @@ export const enTerminalMessages: Messages = {
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
+4
View File
@@ -370,6 +370,7 @@ export const enVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.pathConflict.title': 'Transfer blocked',
@@ -974,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+21
View File
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -565,6 +582,7 @@ export const esAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
@@ -693,6 +711,9 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
+86
View File
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -913,6 +998,7 @@ Resalta el panel dividido enfocado:
'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.toggle-host-tree': 'Alternar lista de hosts',
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
+5
View File
@@ -1,4 +1,9 @@
export const esMonitorMessages = {
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
"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",
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
+52
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
'terminal.toolbar.sessionLog.open': 'Abrir',
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
@@ -68,6 +77,8 @@ export const esTerminalMessages: Messages = {
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
@@ -208,6 +219,7 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -664,6 +676,45 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
@@ -830,6 +881,7 @@ export const esTerminalMessages: Messages = {
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
+16
View File
@@ -370,6 +370,7 @@ export const esVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
@@ -964,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
@@ -1043,6 +1047,18 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
+1
View File
@@ -521,6 +521,7 @@ export const ruAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.refreshModelList': 'Обновить список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
+1
View File
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
+5
View File
@@ -1,4 +1,9 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
+12
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
'terminal.toolbar.sessionLog.open': 'Открыть',
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
@@ -89,6 +98,8 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
@@ -875,6 +886,7 @@ export const ruTerminalMessages: Messages = {
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
+4
View File
@@ -413,6 +413,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
@@ -1013,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en";
import es from "./es";
import ru from "./ru";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
test("folder cleanup-warning counts are localized in every supported locale", () => {
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
}
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en';
import es from './es';
import ru from './ru';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const keys = [
'terminal.toolbar.openSessionLog',
'terminal.toolbar.sessionLogActions',
'terminal.toolbar.sessionLog.menu',
'terminal.toolbar.sessionLog.open',
'terminal.toolbar.sessionLog.reveal',
'terminal.toolbar.sessionLog.chooseApplication',
'terminal.toolbar.sessionLog.systemDefault',
'terminal.toolbar.openSessionLogFailed',
] as const;
test('saved session log actions are translated in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
}
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
});
+1
View File
@@ -582,6 +582,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.refreshModelList': '刷新模型列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
+1
View File
@@ -1035,6 +1035,7 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.pathConflict.title': '无法开始传输',
@@ -1,4 +1,9 @@
export const zhCNMonitorMessages = {
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
'terminal.toolbar.sessionLog.open': '打开',
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
@@ -612,6 +621,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -959,6 +969,7 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
+5
View File
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -524,6 +527,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.sendToAll': '发送到所有会话',
'terminal.composeBar.sendToActive': '仅发送到当前会话',
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
+1
View File
@@ -572,6 +572,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.refreshModelList': '重新整理模型清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
+1
View File
@@ -1037,6 +1037,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.pathConflict.title': '無法開始傳輸',
@@ -1,4 +1,9 @@
export const zhTWMonitorMessages = {
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
'terminal.toolbar.sessionLog.open': '開啟',
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
@@ -612,6 +621,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
@@ -959,6 +969,7 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.flowControl': '流量控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬體',
'serial.type.pseudo': '虛擬終端',
+5
View File
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
@@ -524,6 +527,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
+100
View File
@@ -717,3 +717,103 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
});
}
});
function freezeHistory<T>(value: T): T {
if (value && typeof value === 'object') {
Object.values(value).forEach(freezeHistory);
Object.freeze(value);
}
return value;
}
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
return Array.from({ length: count }, (_, index) => makeSession(
`session-${index}`,
count - index,
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
id: `message-${index}-${messageIndex}`,
role: messageIndex % 2 ? 'assistant' : 'user',
content: 'ordinary text '.repeat(8),
timestamp: messageIndex,
...(messageIndex % 2 ? {
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
providerContinuation: {
reasoningParts: [{
text: 'reasoning',
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
}],
},
} : {}),
})),
));
}
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
const expected = sessions.slice(0, 50);
const expectedJson = JSON.stringify(expected);
const stringify = t.mock.method(JSON, 'stringify');
const result = serializeSessionsForStorage(sessions);
assert.equal(stringify.mock.callCount(), expected.length);
assert.equal(result.json, expectedJson);
assert.deepEqual(result.sessions, expected);
assert.equal(result.sessions[0], sessions[0]);
});
}
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = makePersistenceHistory(1);
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
freezeHistory(sessions);
const fullJson = JSON.stringify(sessions);
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
for (const budget of [fullJson.length, fullJson.length + 1]) {
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
}
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
assert.ok(overflow.json.length < fullJson.length);
assert.equal(JSON.stringify(sessions), fullJson);
});
test('fit-first empty history preserves output even below the minimum array budget', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
for (const budget of [0, 1, 2, 3]) {
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
}
});
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(51, 201));
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
const write = t.mock.method(localStorageAdapter, 'write', () => false);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
assert.equal(writeSessionsForStorage(sessions), false);
assert.equal(write.mock.callCount(), 1);
assert.equal(write.mock.calls[0].arguments[1], sessions);
assert.equal(writeString.mock.callCount(), 0);
read.mock.mockImplementation(() => ({ enabled: false }));
const error = new Error('non-quota storage failure');
writeString.mock.mockImplementation(() => { throw error; });
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
assert.equal(writeString.mock.callCount(), 1);
});
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(20));
const stringify = t.mock.method(JSON, 'stringify');
const result = serializeSessionsForStorage(sessions, budget);
// Same work as the original ladder: no second full-session serialization
// after the fit check, even when a last-resort degraded chat is needed.
assert.equal(stringify.mock.callCount(), expectedCalls);
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
});
}
+19 -2
View File
@@ -363,12 +363,30 @@ export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
return { session, json: JSON.stringify(session) };
});
// Check whether retained history fits before preparing degradation. Use
// the existing JSON string-length budget including array delimiters, and
// avoid preparing unused stripped messages and serializing payloads again.
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, prepared.length - 1);
if (fullLength <= budgetBytes) {
return {
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
// Reuse the full representation when the unchanged degradation ladder is
// needed, including on quota retries with a smaller budget.
const serialized = prepared.map(({ session, json }) => {
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
@@ -379,7 +397,6 @@ export function serializeSessionsForStorage(
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
const json = JSON.stringify(session);
return {
session,
json,
+14 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
@@ -145,3 +145,16 @@ test("a closing destination rejects a popout without releasing source content",
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
test("detached editor dirty-check replies echo the main-process request", (t) => {
let query!: (request: { requestId: string; deadline: number }) => void;
const replies: unknown[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
}));
t.after(subscribeEditorWindowDirtyCheck(() => true));
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
});
+2 -2
View File
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors(() => {
return bridge.onCheckDirtyEditors((request) => {
let hasDirty = false;
try {
hasDirty = cb() === true;
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
} catch {
// ignore
}
+255
View File
@@ -0,0 +1,255 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
+53
View File
@@ -0,0 +1,53 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { performSessionLogAction } from './sessionLogActions';
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
const calls: unknown[][] = [];
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
let available = true;
let failOpen = false;
const bridge = {
prepareSessionLog: async (payload: { sessionId: string }) => {
calls.push(['prepare', payload]);
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
: { success: false, error: 'No saved log for this session' };
},
revealSessionLog: async (payload: { sessionId: string }) => {
calls.push(['reveal', payload]);
return { success: true };
},
selectApplication: async () => { calls.push(['choose']); return picked; },
openWithApplication: async (file: string, app: string) => {
calls.push(['application', file, app]);
if (failOpen) throw new Error('Launch failed');
return true;
},
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
};
Object.assign(dom.window, { netcatty: bridge });
try {
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
await performSessionLogAction('second', 'chooseApplication');
assert.deepEqual(calls.splice(0), [
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
['application', '/logs/second.html', picked!.path],
]);
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
await performSessionLogAction('first', 'reveal');
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
await performSessionLogAction('first', 'systemDefault');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
calls.length = 0;
picked = null;
await performSessionLogAction('first', 'chooseApplication');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
available = false;
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
available = true;
picked = { path: '/missing-editor', name: 'Missing' };
failOpen = true;
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
} finally {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
dom.window.close();
}
});
// The integration boundary must translate backend errors, including ordinary
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
test('terminal log action errors use the translated toast instead of raw backend text', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
source.indexOf(' const handleToggleSessionLog ='));
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
assert.doesNotMatch(handler, /err\.message/);
});
+48
View File
@@ -0,0 +1,48 @@
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
type LogApplication = { path: string; name: string };
/** Resolve the session's actual log in its owning process before opening it. */
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
if (action === 'reveal') {
const result = await bridge.revealSessionLog?.({ sessionId });
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
return;
}
// Check availability before presenting an application picker. Resolve again
// afterwards so any output received while the picker was open is flushed.
let result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
if (action === 'chooseApplication') {
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
application = await bridge.selectApplication();
if (!application) return;
result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
} else if (action === 'systemDefault') {
application = null;
}
if (application?.path) {
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
const opened = await bridge.openWithApplication(result.filePath, application.path);
if (!opened) throw new Error('Failed to open session log');
} else {
const opened = await bridge.openWithSystemDefault?.(result.filePath);
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
}
// Only remember a choice after a successful launch; failed launches leave
// the previous preference intact and can be retried or reset from the menu.
if (action === 'chooseApplication') {
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
} else if (action === 'systemDefault') {
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
}
}
@@ -91,6 +91,7 @@ test('session actions register and unregister', () => {
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendHostToWorkspaceConfirmed: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
@@ -102,6 +103,10 @@ test('session actions register and unregister', () => {
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
toggleComposeSendTarget: noop,
isComposeSendToAll: noop,
toggleGlobalComposeSendTarget: noop,
isGlobalComposeSendToAll: false,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
@@ -57,6 +57,7 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
@@ -68,8 +69,12 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleComposeSendTarget'
| 'isComposeSendToAll'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'toggleGlobalComposeSendTarget'
| 'isGlobalComposeSendToAll'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
@@ -10,6 +10,18 @@ const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
for (const error of [
"read ECONNABORTED",
"socket hang up",
"ERR_SFTP_CHANNEL_CLOSED",
"SFTP pipelined upload failed: socket hang up",
"client-socket",
]) {
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
}
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
@@ -4,7 +4,11 @@ import type { TransferTask } from "../../../domain/models";
// .part is intact up to the checkpoint, so Retry can continue from it instead
// of restarting at byte 0 (which also strands the old stage on the server,
// because the new attempt stages under a fresh transfer id).
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
// cover every code or message isSftpChannelLossError already treats as channel
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
// client-socket). A miss here restarts at byte 0 and strands the .part.
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
/**
* True when Retry should resume the same task from its checkpoint.
@@ -163,6 +163,9 @@ export async function restoreSftpTransferHistoryCooperatively(
const child = parentChildren[childIndex];
if (
child.status === "completed"
// Match canonical pruning: warning rows carry recovery paths that
// cannot be represented by the directory's compact manifest.
&& !child.warning
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
@@ -135,6 +135,34 @@ test("completed dispatch consumes only exact identity and never restarts", async
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
store.upsertTasks([root, task]);
t.after(() => store.dismiss(root.id));
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
assert.equal(result?.warning, warning);
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
assert.equal(store.getTask(task.id)?.warning, warning);
});
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => store.dismiss(task.id));
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
+1
View File
@@ -65,6 +65,7 @@ export interface FileWatchSyncedEvent {
localPath: string;
remotePath: string;
bytesWritten: number;
warning?: string;
}
export interface FileWatchErrorEvent {
@@ -156,6 +156,10 @@ export const createUploadTaskCallbacks = ({
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
});
},
onTaskWarning: (taskId: string, warning: string) => {
// Preserve cleanup details before completion can compact a child row.
store.patchTask(taskId, { warning });
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
store.patchTask(taskId, {
status: "completed" as TransferStatus,
+12 -2
View File
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
@@ -37,7 +37,17 @@ export async function runTransferAndWaitForOwner(
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) return result;
if (!result?.superseded) {
// The invoke response may arrive before completed IPC. Preserve cleanup
// details before the caller completes (and may compact) this child.
if (result?.warning && !result.error && !result.cancelled) {
const latest = sftpTransferCenterStore.getTask(task.id);
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
}
}
return result;
}
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
@@ -33,6 +33,85 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
]);
});
test("completion cleanup warnings survive owner snapshots and restored history", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = makeTask("cleanup-warning");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
assert.equal(restored.getTask(source.id)?.status, "completed");
assert.equal(restored.getTask(source.id)?.error, undefined);
});
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-snapshot");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
});
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-event");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
});
test("late completion enriches a locally completed task with its cleanup warning", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const endTime = Date.now();
store.publishOwner("panel", [source]);
// The invoke response can settle the renderer before the completed IPC arrives.
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
let notifications = 0;
const unsubscribe = store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
unsubscribe();
});
for (const status of ["failed", "cancelled"] as const) {
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
const store = createSftpTransferCenterStore();
const endTime = Date.now();
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
store.publishOwner("panel", [task]);
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
assert.equal(store.getTask(task.id)?.status, status);
assert.equal(store.getTask(task.id)?.error, "original outcome");
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
const calls: string[] = [];
const store = createSftpTransferCenterStore();
@@ -100,6 +179,10 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
for (const [label, error, expectResume] of [
["dead channel", "SFTP channel closed", true],
["lost session", "SFTP session lost. Please reconnect.", true],
["aborted socket", "read ECONNABORTED", true],
["hung up socket", "socket hang up", true],
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
["client socket", "client-socket", true],
["content failure", "Permission denied", false],
] as const) {
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
@@ -3572,6 +3655,46 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
});
for (const indexed of [false, true]) {
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
const now = Date.now();
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
const children: TransferTask[] = [0, 1].map((index) => ({
...makeTask(`restore-warning-child-${index}`, "completed"),
parentTaskId: parent.id, endTime: now,
warning: index === 0 ? warning : undefined,
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
}));
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for cleanup-warning history migration"));
}, 10_000);
const check = () => {
if (!store.getTask(parent.id)) return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
});
}
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
const legacyOnly = makeTask("legacy-only", "paused");
const staleCollision = makeTask("collision", "paused");
@@ -152,6 +152,7 @@ export interface SftpTransferCenterStore {
startedAt?: number;
endedAt?: number;
error?: string;
warning?: string;
transferred?: number;
totalBytes?: number;
speed?: number;
@@ -289,6 +290,12 @@ export function mergeOwnerPublishedTask(
updatedAt: incoming.updatedAt ?? Date.now(),
};
// A panel snapshot may predate cleanup metadata returned by the invoke or
// completed IPC. Keep it through this attempt, but not a restart or failure.
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
merged.warning = incoming.warning ?? existing.warning;
}
if (incomingReset || incomingTerminal) {
return merged;
}
@@ -2274,6 +2281,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (terminal && event.type !== "cancelled") {
// Allow a late explicit cancel to stick; ignore everything else.
if (event.type === "completed" || event.type === "failed") {
// An invoke response can complete the renderer row before its IPC
// event arrives. Enrich that same success with cleanup metadata,
// without replacing any terminal outcome or reviving stopped work.
if (existing.status === "completed" && event.type === "completed"
&& event.warning && event.warning !== existing.warning) {
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
emit(true);
return;
}
// Keep existing terminal state (prefer cancelled over late completed).
publishRepairedHierarchy();
return;
@@ -2561,6 +2577,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
? task.checkpointBytes
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
error: event.error,
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
endTime: event.endedAt ?? Date.now(),
speed: 0,
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
@@ -9,6 +9,106 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
const sessions = ["images", "plain", "other"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("images");
terminalHiddenRendererStore.markSoftHidden("plain");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
terminalHiddenRendererStore.clearSoftHidden("plain");
terminalHiddenRendererStore.settleEviction("plain", true);
}
});
try {
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
terminalHiddenRendererStore.markSoftHidden("images");
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
const delivered: string[] = [];
const later = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
});
try {
terminalHiddenRendererStore.markSoftHidden("other");
assert.deepEqual(delivered, []);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
} finally {
later();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a reserved leaving slot counts toward occupancy until it is released", () => {
const sessions = ["a", "b", "c", "d"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
terminalHiddenRendererStore.clearSoftHidden("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
terminalHiddenRendererStore.clearSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("victim");
let markerClearedBeforeSettle = false;
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.settleEviction("victim", false);
});
});
try {
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
assert.equal(markerClearedBeforeSettle, true);
assert.equal(completed, false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
} finally {
unsubscribe();
terminalHiddenRendererStore.clearSoftHidden("victim");
}
});
@@ -3,14 +3,85 @@ type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
const reservedLeavingSlots = new Map<string, number>();
let reservationToken = 0;
function reservedLeavingCount(): number {
let extra = 0;
for (const sessionId of reservedLeavingSlots.keys()) {
if (!softHiddenSessions.has(sessionId)) extra += 1;
}
return extra;
}
type EvictionWaiter = {
promise: Promise<boolean>;
resolve: (completed: boolean) => void;
settled: boolean;
};
const evictionWaiters = new Map<string, EvictionWaiter>();
function finishEviction(sessionId: string, completed: boolean): void {
const waiter = evictionWaiters.get(sessionId);
if (!waiter || waiter.settled) return;
waiter.settled = true;
evictionWaiters.delete(sessionId);
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
waiter.resolve(completed);
}
function emit(): void {
listeners.forEach((listener) => listener());
}
function listSoftHiddenOldestFirst(): string[] {
return [...softHiddenSessions.entries()]
.sort((left, right) => left[1] - right[1])
.map(([sessionId]) => sessionId);
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
/**
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
* yet in the marker set. Keep-count decisions use this so a second timer
* cannot take the slot a pending eviction already claimed.
*/
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
/**
* Claim one keep slot for a leaving tab before awaiting eviction.
* A session already in the marker set is not counted twice. A later claim
* for the same id replaces the token; only that token can release it.
*/
reserveLeavingSlot: (sessionId: string): number => {
const token = ++reservationToken;
if (sessionId && !softHiddenSessions.has(sessionId)) {
reservedLeavingSlots.set(sessionId, token);
}
return token;
},
releaseLeavingSlot: (sessionId: string, token: number) => {
if (reservedLeavingSlots.get(sessionId) === token) {
reservedLeavingSlots.delete(sessionId);
}
},
/**
* True when retaining this leaving tab would stay within keepCount.
* Its own reservation is not counted against the slots held by others.
*/
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
if (!(keepCount > 0)) return false;
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
return others < keepCount;
},
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
@@ -23,18 +94,12 @@ export const terminalHiddenRendererStore = {
emit();
},
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
return listSoftHiddenOldestFirst()[0] ?? null;
},
requestEviction: (sessionId: string) => {
@@ -42,6 +107,44 @@ export const terminalHiddenRendererStore = {
emit();
},
/** True while this session's hibernate has been requested and not settled. */
hasPendingEviction: (sessionId: string): boolean => {
const waiter = evictionWaiters.get(sessionId);
return waiter != null && !waiter.settled;
},
/**
* Ask one soft-hidden session to upgrade. Resolves true only when
* settleEviction reports that hibernation finished. Clearing the marker
* during wake is not success: drain, snapshot, or alternate-screen failure
* can still restore that renderer. A synchronous refusal (marker still
* present, including inline images) resolves false and drops an unconsumed
* request so a later emit cannot upgrade it.
*/
evictSoftHidden: (sessionId: string): Promise<boolean> => {
const existing = evictionWaiters.get(sessionId);
if (existing) return existing.promise;
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
let resolvePromise!: (completed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
evictionWaiters.set(sessionId, waiter);
evictionRequestSessionId = sessionId;
emit();
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
finishEviction(sessionId, false);
}
return promise;
},
/** Record whether the in-flight eviction's hibernate actually completed. */
settleEviction: (sessionId: string, completed: boolean) => {
finishEviction(sessionId, completed);
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
@@ -125,6 +125,55 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
return node;
}
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
const f = await fixture(t);
panel(f, 900, 0, 400, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
const editor = panel(f, 450, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true);
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
assert.deepEqual(f.panel().snapshot, []);
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
f.resize(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
editor.parentElement!.remove(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
const f = await fixture(t);
panel(f, 0, 0, 300, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
const select = f.overlay(tooltip, 140, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
select.remove();
f.overlay(tooltip, 320, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
'pictures are placed where the narrowed page sits inside the pane');
});
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
const f = await fixture(t);
const middle = panel(f, 300, 0, 100, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.deepEqual(f.panel().snapshot, layers);
middle.parentElement!.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
panel(f, 250, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
});
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');
+26 -9
View File
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/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 {
function overlapsBrowser(rect: HostBrowserPaneRect, 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;
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
@@ -70,12 +71,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
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.
// Overlay panels stay open while the user works, so the live page moves
// beside them when it can and only falls back to a picture otherwise.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const overlays = Array.from(document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
+ '[data-native-view-occluder]',
));
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
// Poppers over the part of the pane hidden behind a panel (e.g. a select
// inside the host editor) no longer touch the live page.
const covered = modal || (!!rect && !live)
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
const targets = new Set<Element>(overlays);
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
for (const target of observed) {
@@ -90,7 +100,11 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
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 pane = live || rect;
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
// Pictures are relative to the native page, which may sit beside a panel.
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
@@ -102,7 +116,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const revision = ++layoutRevision;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || layoutRevision !== revision) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
})) } : null);
}).catch(() => {});
}
};
@@ -119,14 +135,15 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
if (doc) mutationObserver?.observe(doc.documentElement, {
subtree: true, childList: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
'href', 'media', 'disabled'],
});
const onMotion = (event: Event) => {
const target = event.target;
if (!element || !view || !(target instanceof view.Element)) return;
if (!target.contains(element) && !target.matches(
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
)) return;
trackAnimations(target);
schedule();
+4
View File
@@ -54,6 +54,8 @@ export interface ServerStats {
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
diskWriteSpeed?: number | null;
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
@@ -240,6 +242,8 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
diskReadSpeed: stats.diskReadSpeed ?? null,
diskWriteSpeed: stats.diskWriteSpeed ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
+1 -1
View File
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
const bridge = netcattyBridge.get();
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
}, []);
return {
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("compose send target follows toolbar broadcast and then flips independently", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isComposeSendToAll(workspaceId), true);
assert.equal(current().isBroadcastEnabled(workspaceId), true);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
assert.equal(current().canUseGlobalBroadcast, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
const closedId = current().orphanSessions[1].id;
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+76 -11
View File
@@ -1,4 +1,5 @@
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { flushSync } from 'react-dom';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import {
@@ -25,6 +26,10 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
} from '../../domain/terminalBroadcast';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -235,8 +240,12 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
// then flip it without changing keystroke fan-out.
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -907,6 +916,25 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Agent callers bind scope immediately, so they need committed insertion,
// not just the ID allocated by the ordinary batched UI append operation.
const appendHostToWorkspaceConfirmed = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
let sessionId: string | null = null;
flushSync(() => {
sessionId = appendHostToWorkspace(workspaceId, host, direction);
});
if (!sessionId) return null;
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const inserted = committedWorkspace
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
return inserted ? sessionId : null;
}, [appendHostToWorkspace]);
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
// but constructs a local-protocol session instead of an SSH one.
const appendLocalTerminalToWorkspace = useCallback((
@@ -1137,8 +1165,10 @@ export const useSessionState = ({
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
// Compose-bar send target follows the toolbar switch back to active-only.
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
@@ -1273,31 +1303,61 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
setComposeSendAllWorkspaceIds((prev) => {
const hasAll = prev.has(workspaceId);
if ((target === 'all') === hasAll) return prev;
const next = new Set(prev);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
if (target === 'all') next.add(workspaceId);
else next.delete(workspaceId);
return next;
});
}, []);
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
const toggleBroadcast = useCallback((workspaceId: string) => {
const enabling = !broadcastWorkspaceIds.has(workspaceId);
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (prev.has(workspaceId)) next.delete(workspaceId);
else next.add(workspaceId);
return next;
});
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
setWorkspaceComposeSendTarget(
workspaceId,
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
);
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
const isComposeSendToAll = useCallback((workspaceId: string) => {
return composeSendAllWorkspaceIds.has(workspaceId);
}, [composeSendAllWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => !prev);
setGlobalBroadcastEnabled(prev => {
const next = !prev;
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
return next;
});
}, []);
const toggleGlobalComposeSendTarget = useCallback(() => {
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const isGlobalComposeSendToAll = globalComposeSendToAll;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
@@ -1413,6 +1473,7 @@ export const useSessionState = ({
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
@@ -1426,8 +1487,12 @@ export const useSessionState = ({
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { collectSessionIds } from '../../domain/workspace';
import { activeTabStore } from './activeTabStore';
import { useSessionState } from './useSessionState';
test('workspace host opening acknowledges committed insertion and rejects queued closure', async (t) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
const globals = {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
CustomEvent: dom.window.CustomEvent,
Event: dom.window.Event,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
const root = createRoot(document.getElementById('root')!);
t.after(async () => {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
activeTabStore.setActiveTabId('vault');
});
let state: ReturnType<typeof useSessionState>;
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(Probe))));
const host = createLocalShellHost({ id: 'local-inheriting' });
await act(async () => state.createWorkspaceWithHosts('Target', [host]));
const workspaceId = state!.workspaces[0].id;
const resolvedHost = resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/tmp/project' },
});
let ordinaryId: string | null = null;
await act(async () => {
ordinaryId = state.appendHostToWorkspace(workspaceId, resolvedHost);
});
assert.ok(ordinaryId);
assert.ok(state!.sessions.some(session => session.id === ordinaryId));
assert.ok(collectSessionIds(state!.workspaces[0].root).includes(ordinaryId));
let insertedId: string | null = null;
await act(async () => {
insertedId = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.ok(insertedId, 'success is returned after both state updates commit');
const session = state.sessions.find(s => s.id === insertedId);
assert.equal(session?.localShell, '/bin/zsh');
assert.deepEqual(session?.localShellArgs, ['-l']);
assert.equal(session?.localStartDir, '/tmp/project');
assert.equal(state.sessions.filter(s => s.id === insertedId).length, 1);
assert.equal(collectSessionIds(state.workspaces[0].root).filter(id => id === insertedId).length, 1);
});
await act(async () => {
// The rendered ref still contains the workspace; its close updater is queued.
state.closeWorkspace(workspaceId);
const result = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.equal(result, null, 'must not acknowledge an allocated but uninserted ID');
});
assert.equal(state!.workspaces.length, 0);
assert.equal(state!.sessions.length, 0, 'no orphan session survives the queued close');
assert.equal(activeTabStore.getActiveTabId(), 'vault');
await act(async () => {
assert.equal(state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost), null);
});
});
test('agent workspace host opening uses the common launch and history handler', () => {
const source = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /const sessionId = handleConnectToHost\(host, true, hidden, target\.workspaceId\)/);
assert.match(source, /connectToHost: workspaceId\s*\? \(candidate: Host\) => appendHostToWorkspaceConfirmed\(workspaceId, candidate\)\s*: connectToHost/);
});
+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -1,8 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import React, { startTransition, useState } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
@@ -130,3 +135,231 @@ test('closing the final pane removes only that terminal side panel state', async
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
let renders = 0;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
renders += 1;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 1, 'open tabs and layout share one commit');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
const layout = state!.sidePanelLayouts.get('terminal-a')!;
assert.equal(
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
'notes',
);
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
assert.deepEqual(
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
['notes', 'ai'],
);
assert.equal(
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
'notes',
);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
assert.notEqual(focusedExisting, beforeFocus);
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
const settled = state!.sidePanelLayouts;
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 0);
assert.equal(state!.sidePanelLayouts, settled);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('a pending side-panel write survives an urgent render on another tab', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let bump: ((value: number) => void) | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
const [, setTick] = useState(0);
bump = setTick;
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const tools = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
bump?.(1);
});
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
});
});
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.deepEqual(tools('terminal-b'), ['sftp']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
const body = layerSource.slice(start, end);
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
});
@@ -13,55 +13,215 @@ import {
type SidePanelTool,
} from '../../domain/sidePanelLayout';
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
if (previous.get(key) === value) return previous;
const next = new Map(previous);
next.set(key, value);
return next;
}
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
if (!previous.has(key)) return previous;
const next = new Map(previous);
next.delete(key);
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
): Map<string, SidePanelLayout> | null {
let changed = false;
const next = new Map(layouts);
for (const tabId of layouts.keys()) {
if (!openTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
for (const [tabId, tool] of openTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
return changed ? next : null;
}
export function useTerminalSidePanelLayoutState() {
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = sidePanelLayouts;
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
renderedLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
// public application boundary into the per-tab tree without disturbing the
// other panes in an existing layout.
// other panes in an existing layout. Skip the setState when the click path
// already stored this selection: scheduling an identical update still
// renders again after a paired openTabs write.
useEffect(() => {
const base = sidePanelLayoutsRef.current;
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
if (!reconciled) return;
sidePanelLayoutsRef.current = reconciled;
setSidePanelLayouts((previous) => {
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
const next = previous === base
? reconciled
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
if (!next) {
sidePanelLayoutsRef.current = previous;
return previous;
}
for (const [tabId, tool] of sidePanelOpenTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
if (changed) sidePanelLayoutsRef.current = next;
return changed ? next : previous;
sidePanelLayoutsRef.current = next;
return next;
});
}, [sidePanelOpenTabs]);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
const focusedTool = getFocusedSidePanelPane(layout).tool;
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
const commitMap = useCallback((
tabId: string,
layout: SidePanelLayout,
openTab: SidePanelTool | null,
) => {
const layoutBase = sidePanelLayoutsRef.current;
const openTabBase = sidePanelOpenTabsRef.current;
const layouts = openTab === null
? deleteMapEntry(layoutBase, tabId)
: replaceMapEntry(layoutBase, tabId, layout);
const openTabs = openTab === null
? deleteMapEntry(openTabBase, tabId)
: replaceMapEntry(openTabBase, tabId, openTab);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
setSidePanelLayouts((previous) => {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
return next;
});
}, []);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
}, [commitMap]);
// Click path writes the focused tool and the layout together. The effect
// above still reconciles external openTabs writes, and skips when
// selectSidePanelTool returns the layout this call just stored.
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
const current = sidePanelLayoutsRef.current.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
commitLayout(tabId, updated);
}, [commitLayout]);
const focusPane = useCallback((tabId: string, paneId: string) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
@@ -97,25 +257,23 @@ export function useTerminalSidePanelLayoutState() {
return false;
}
const layouts = new Map(sidePanelLayoutsRef.current);
const openTabs = new Map(sidePanelOpenTabsRef.current);
layouts.delete(tabId);
openTabs.delete(tabId);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
commitMap(tabId, layout, null);
return true;
}, [commitLayout]);
}, [commitLayout, commitMap]);
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
if (nextLayout === layout) return;
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
const base = sidePanelLayoutsRef.current;
const layouts = replaceMapEntry(base, tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts(layouts);
setSidePanelLayouts((previous) => {
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
sidePanelLayoutsRef.current = next;
return next;
});
}, []);
return {
@@ -129,5 +287,6 @@ export function useTerminalSidePanelLayoutState() {
splitPane,
closePane,
resizeSplit,
selectSidePanelToolForTab,
};
}
+1 -1
View File
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
stopTunnel: current.stopTunnel,
stopRuleTunnels: current.stopRuleTunnels,
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
const terminalLayerSource = readFileSync(
new URL("../../components/TerminalLayer.tsx", import.meta.url),
"utf8",
);
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
assert.match(source, /await writeTerminalDataMapDiff\(/);
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
assert.doesNotMatch(
source,
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
);
});
test("close-path connection log updates await the indexedDB persist task", () => {
assert.match(
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
);
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
assert.match(source, /if \(!map\) return;/);
assert.match(
source,
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
);
assert.match(
terminalLayerSource,
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
);
});
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
assert.notEqual(revAt, -1);
assert.ok(revAt < bootAt);
const bootSlice = source.slice(bootAt, bootAt + 2800);
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
assert.match(
bootSlice,
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
);
assert.match(
source,
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
);
});
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+422 -112
View File
@@ -37,6 +37,7 @@ import {
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
@@ -61,11 +62,23 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
acknowledgePendingConnectionLogWrites,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
type PendingConnectionLogWrite,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
migrateLegacyConnectionLogTerminalData,
writeTerminalDataMapDiff,
type ConnectionLogTerminalDataRev,
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
@@ -227,6 +240,13 @@ const safeParse = <T,>(value: string | null): T | null => {
}
};
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
const first = await tryLoadConnectionLogTerminalData();
if (first) return first;
return tryLoadConnectionLogTerminalData();
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
@@ -255,9 +275,6 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
return stored?.showLineTimestamps === true;
};
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
const readPersistedConnectionLogIds = (): Set<string> => {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
return new Set(stored.map((log) => log.id));
@@ -276,23 +293,18 @@ const buildPersistedLogIds = (
return ids;
};
const writeConnectionLogTerminalDataMap = (
logs: ConnectionLog[],
localMaps: ConnectionLogTerminalDataMap[],
usePersistedLogIdsFromDisk: boolean,
): boolean => {
const existing = readConnectionLogTerminalDataMap();
const pruned = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
export type UseVaultStateOptions = {
/**
* When false, skip loading and cross-window sync of the bulky
* connection-log terminal-data side store (~30–50 MB in localStorage).
* Pass false from windows that never show connection-log replay
* (Settings, tray panel, terminal popup). Default true.
*/
syncConnectionLogTerminalData?: boolean;
};
export const useVaultState = () => {
export const useVaultState = (options?: UseVaultStateOptions) => {
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
@@ -418,67 +430,158 @@ export const useVaultState = () => {
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogWriteGenerationRef = useRef(0);
const connectionLogPendingRef = useRef(new Map<string, PendingConnectionLogWrite>());
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
const connectionLogTerminalDataLoadSeq = useRef(0);
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
const pluginCredentialCatalogVersion = useRef(0);
const syncConnectionLogTerminalDataMap = useCallback((
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
const run = terminalDataQueueRef.current.then(task, task);
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
return run;
}, []);
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
const payload: ConnectionLogTerminalDataRev = {
rev: Math.max(prev + 1, Date.now()),
ids: [...ids].sort(),
nonce: crypto.randomUUID(),
};
// Record the exact JSON before setItem so this window ignores its own echo.
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
if (!written) {
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
}
}, []);
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const map = await loadConnectionLogTerminalData();
if (!map) return;
connectionLogTerminalDataRevRawRef.current = raw;
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = {
...map,
...connectionLogTerminalDataRef.current,
};
}, []);
const syncConnectionLogTerminalDataMap = useCallback(async (
logs: ConnectionLog[],
options?: { usePersistedLogIdsFromDisk?: boolean },
) => {
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
const localMaps = [
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
];
const existing = readConnectionLogTerminalDataMap();
if (!syncConnectionLogTerminalData) return true;
await absorbRemoteTerminalDataIfNeeded();
const baseline = connectionLogTerminalDataPersistedRef.current;
const merged = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
baseline,
[
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
],
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
);
const persisted = writeConnectionLogTerminalDataMap(
logs,
localMaps,
usePersistedLogIdsFromDisk,
);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
const written = await writeTerminalDataMapDiff(baseline, merged);
// Keep the capped map in memory even when IndexedDB rejects the write, so
// the next close retries from the last committed baseline.
connectionLogTerminalDataRef.current = merged;
if (!written.changed) return true;
if (!written.persisted) return false;
connectionLogTerminalDataPersistedRef.current = merged;
bumpConnectionLogTerminalDataRev(Object.keys(merged));
return true;
}, [
absorbRemoteTerminalDataIfNeeded,
bumpConnectionLogTerminalDataRev,
syncConnectionLogTerminalData,
]);
const persistConnectionLogState = useCallback((
const persistConnectionLogState = useCallback(async (
logs: ConnectionLog[],
options?: { pruneMainBlob?: boolean },
options?: {
pruneMainBlob?: boolean;
removedIds?: ReadonlySet<string>;
changedIds?: ReadonlySet<string>;
},
) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainReplay: ConnectionLogTerminalDataMap = {};
const writeMain = (value: ConnectionLog[]) => {
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, value);
if (written) mainReplay = buildTerminalDataMapFromLogs(value);
return written;
};
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
mainPersisted = writeMain(prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
mainPersisted = writeMain(logs);
}
}
let sidePersisted = true;
let sidePersisted = false;
if (mainPersisted || !options?.pruneMainBlob) {
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
}));
});
}
if (!options?.pruneMainBlob) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
// The side-store await can overlap another window's metadata write.
// Rebase this snapshot onto the blob that exists now, immediately before
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const toWrite = freshLogs
? rebaseQueuedConnectionLogs(
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
freshLogs,
connectionLogTerminalDataPersistedRef.current,
connectionLogPendingRef.current,
),
freshLogs,
)
: logs;
mainPersisted = writeMain(toWrite);
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
// IndexedDB rejected the replay after the pruned metadata write. Put the
// buffers back on the current main blob so a restart still has them,
// without replacing logs another window wrote during the await.
// If that blob exceeds quota, drop older unsaved replays and retry so
// the newest capture still has a durable copy.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = writeMain(fallbackLogs);
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
while (!sidePersisted && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
sidePersisted = writeMain(fallbackLogs);
}
}
if (!mainPersisted && unsavedTerminalDataPending) {
@@ -490,15 +593,105 @@ export const useVaultState = () => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
options?: { pruneMainBlob?: boolean },
) => {
const prev = connectionLogsRef.current;
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const generation = ++connectionLogWriteGenerationRef.current;
for (const log of next) {
nextIds.add(log.id);
const before = prevById.get(log.id);
if (!before || before !== log) {
const previous = connectionLogPendingRef.current.get(log.id);
const changes: Partial<ConnectionLog> = { ...previous?.changes };
const fieldGenerations = { ...previous?.fieldGenerations };
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
if (!before || before[field] !== log[field]) {
Object.assign(changes, { [field]: log[field] });
fieldGenerations[field] = generation;
}
}
connectionLogPendingRef.current.set(log.id, {
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
});
}
}
for (const log of prev) {
if (!nextIds.has(log.id)) {
connectionLogPendingRef.current.set(log.id, { generation, log: null });
}
}
connectionLogsRef.current = next;
setConnectionLogs(next);
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
// The shared lock covers the read/rebase as well as both storage writes.
// Include uncommitted generations from failed or newer queued captures.
await absorbRemoteTerminalDataIfNeeded();
const pending = new Map(connectionLogPendingRef.current);
const removedIds = new Set<string>();
const changedIds = new Set<string>();
for (const [id, mutation] of pending) {
if (mutation.log === null) removedIds.add(id);
else changedIds.add(id);
}
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const memory = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
storedBefore ?? connectionLogsRef.current,
connectionLogTerminalDataPersistedRef.current,
pending,
);
const persisted = await persistConnectionLogState(
rebaseQueuedConnectionLogs(
memory,
removedIds,
changedIds,
memory,
storedBefore,
),
{ ...options, removedIds, changedIds },
);
// A replay/quota failure must not retain already-committed add or
// metadata ownership: retrying it could undo a peer bookmark/deletion.
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
connectionLogPendingRef.current, pending, persisted,
);
if (persisted.metadataPersisted || persisted.replayPersisted) {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (stored) {
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
stored,
connectionLogTerminalDataRef.current,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}
}
return persisted.metadataPersisted && persisted.replayPersisted;
}));
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
storedLogs: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
return mergeConnectionLogsWithPendingMutations(
prev,
storedLogs,
terminalDataMap,
connectionLogPendingRef.current,
);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
@@ -1292,63 +1485,56 @@ export const useVaultState = () => {
...log,
id: crypto.randomUUID(),
};
setConnectionLogs((prev) => {
void commitConnectionLogs((prev) => {
// Keep only the last 500 non-saved entries plus all saved entries
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
return pruneInMemoryUnsavedTerminalData(
[...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
),
);
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
});
}, { pruneMainBlob: true });
return newLog.id;
},
[persistConnectionLogState],
[commitConnectionLogs],
);
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
return commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
)
), { pruneMainBlob: true });
},
[persistConnectionLogState],
[commitConnectionLogs],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, [persistConnectionLogState]);
void commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
),
)
), { pruneMainBlob: false });
}, [commitConnectionLogs]);
const deleteConnectionLog = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
const map = { ...connectionLogTerminalDataRef.current };
delete map[id];
connectionLogTerminalDataRef.current = map;
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
}, [persistConnectionLogState]);
void commitConnectionLogs(
(prev) => prev.filter((log) => log.id !== id),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
persistConnectionLogState(saved, { pruneMainBlob: true });
return saved;
});
}, [persistConnectionLogState]);
void commitConnectionLogs(
(prev) => prev.filter((log) => log.saved),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
@@ -1384,6 +1570,7 @@ export const useVaultState = () => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
@@ -1415,6 +1602,11 @@ export const useVaultState = () => {
useEffect(() => {
let cancelled = false;
let resolveTerminalDataReady = () => {};
const terminalDataReady = new Promise<void>((resolve) => {
resolveTerminalDataReady = resolve;
});
terminalDataReadyRef.current = terminalDataReady;
const init = async () => {
try {
await withVaultImportLock("vault", async () => {
@@ -1624,14 +1816,91 @@ export const useVaultState = () => {
publishShellHistorySnapshot(savedShellHistory);
}
// Load connection logs
// Load connection logs. Replay blobs live in IndexedDB, one row per
// log id. Windows that never render replay skip that read
// (Settings / tray / popup).
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
const terminalDataMap = readConnectionLogTerminalDataMap();
let connectionLogsForBoot = savedConnectionLogs;
let terminalDataMap: ConnectionLogTerminalDataMap = {};
let durableTerminalData = true;
if (syncConnectionLogTerminalData) {
const readTerminalDataRev = () => localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
// A peer can commit replay and bump the rev while this load is in
// flight. Capture the rev first and reload when it moves, so stamping
// the key later does not mark an unread snapshot as consumed.
let revSeen = readTerminalDataRev();
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
),
remove: () => {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
},
});
if (cancelled) return;
terminalDataMap = loaded.map;
let revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
}
}
// Another window can append a log while IndexedDB is opening. Prune
// against that newer metadata so this boot does not delete its replay.
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? savedConnectionLogs;
const pruned = connectionLogsForBoot
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
: terminalDataMap;
// A legacy map that never reached IndexedDB is not a diff baseline.
// Comparing against it would persist only the changed ids and then
// drop the rest of the legacy blob on the next boot.
const baseline = loaded.durable ? terminalDataMap : {};
const written = await writeTerminalDataMapDiff(baseline, pruned);
if (cancelled) return;
if (written.persisted) {
terminalDataMap = pruned;
if (!loaded.durable) {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
}
}
durableTerminalData = written.persisted || loaded.durable;
if (loaded.migrated || (written.changed && written.persisted)) {
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
} else {
revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? connectionLogsForBoot;
}
}
connectionLogTerminalDataRevRawRef.current = revSeen;
}
}
connectionLogTerminalDataRef.current = terminalDataMap;
if (savedConnectionLogs) {
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
if (connectionLogsForBoot) {
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
connectionLogsRef.current = hydrated;
setConnectionLogs(hydrated);
}
// Load managed sources
@@ -1660,6 +1929,7 @@ export const useVaultState = () => {
}
}
} finally {
resolveTerminalDataReady();
// StrictMode remount cancels the first init; only the surviving effect
// may publish "vault ready" or terminals can boot against empty keys.
if (!cancelled) {
@@ -1672,8 +1942,15 @@ export const useVaultState = () => {
void init();
return () => {
cancelled = true;
resolveTerminalDataReady();
};
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
}, [
bumpConnectionLogTerminalDataRev,
commitEncryptedHostsUnderVaultLock,
syncConnectionLogTerminalData,
updateHosts,
updateSnippets,
]);
useEffect(() => {
if (!isInitialized) return;
@@ -1874,17 +2151,22 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
// A queued peer event may predate our successful commit. Read the
// current blob, then preserve only still-owned local generations.
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (!next) return;
const merged = applyConnectionLogsFromStorage(
connectionLogsRef.current,
next,
connectionLogTerminalDataRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(event.newValue);
return;
}
@@ -1912,19 +2194,47 @@ export const useVaultState = () => {
}
};
const applyTerminalDataRev = (raw: string | null) => {
// Settings, tray, and popup mounts never replay logs and must not
// pull the side store across windows.
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
if (!map) return;
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
map,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
})));
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
// This window already updated connectionLogsRef before setItem. The
// adapter notifies on a timer, so this blob can be older than a log
// added or finalized while that write was still queued. Applying it
// would drop that log from the ref, and the follow-up recipe would
// record no changed id. Other windows still arrive through "storage".
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
));
}
};
@@ -1934,7 +2244,7 @@ export const useVaultState = () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage]);
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
+11
View File
@@ -1,3 +1,14 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
+2
View File
@@ -411,6 +411,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
modelCatalogWarning={modelCatalogWarning}
onRefreshModelCatalog={handleModelCatalogRetry}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
+3
View File
@@ -1090,6 +1090,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
target.agentEnv,
target.agentCommand,
target.codexRuntime,
// Manual refresh (retry banner / picker button) must bypass the
// main-side LKG-first policy and go live.
options.force === true,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
@@ -620,6 +620,34 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
assert.equal(cache.read('copilot')?.warning, undefined);
});
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
// A degraded serve (warning) is cached and replayed within the TTL.
await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
warning: 'live fetch failed',
}));
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
// Past the TTL the entry is evicted and the next automatic load re-queries
// main; with the LKG-first policy that round-trip returns a clean
// last-known-good serve, so the stale banner clears.
now = 1_150;
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
source: 'last-known-good',
fetchedAt: '2026-10-23T08:00:00.000Z',
}));
assert.equal(cleared.source, 'last-known-good');
assert.equal(cleared.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
});
// #3584 alignment: OpenCode must render build-time presets while its live
// catalog load is pending, exactly like every other backend. The old host
// code suppressed presets for opencode while `runtimeModelLoading` was set,
+4
View File
@@ -247,6 +247,10 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
// The LKG-first policy lives in main: once a persisted catalog exists
// there, a TTL-expired non-force refresh is answered from disk with a
// clean last-known-good serve (no live attempt, no warning), so the
// next automatic load here clears a stale banner.
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom";
test("global completed rows and expanded folder children show cleanup paths without a failed status", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
MutationObserver: env.window.MutationObserver, NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement, Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter");
const { TooltipProvider } = await import("./ui/tooltip");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const warning = "Upload completed; remove backup /remote/.netcatty-backup-file.bin manually.";
const base = {
fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload" as const,
status: "completed" as const, totalBytes: 10, transferredBytes: 10, speed: 0,
startTime: Date.now(), endTime: Date.now(), isDirectory: false,
};
store.publishOwner("cleanup-ui", [
{ ...base, id: "warning-file", warning },
{ ...base, id: "warning-folder", fileName: "folder", isDirectory: true },
{ ...base, id: "warning-child", parentTaskId: "warning-folder", warning },
]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const click = async (selector: string) => {
const node = env.document.querySelector(selector);
assert.ok(node, selector);
await dispatchDomEvent(node, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
};
await click("[data-section=global-sftp-transfer-toggle]");
assert.match(env.document.body.textContent ?? "", /Backup cleanup warnings: 1/);
await click('button[aria-label="Show files"]');
const notices = env.document.querySelectorAll('[data-section="sftp-transfer-warning"]');
assert.equal(notices.length, 2);
for (const notice of notices) assert.equal(notice.textContent, warning);
assert.equal(env.document.querySelector('[data-transfer-status="failed"]'), null);
assert.equal(store.getTask("warning-child")?.status, "completed");
await click("[data-section=global-sftp-transfer-toggle]");
});
+11 -1
View File
@@ -37,6 +37,7 @@ import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { SftpTransferWarning } from "./sftp/SftpTransferWarning";
/** Stable empty snapshot while the popover is closed — progress ticks must not
* re-render TopTabs just to update hidden list rows. */
@@ -350,7 +351,9 @@ const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTa
return (
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{task.status === "completed" && task.warning
? <div className="min-w-0 flex-1"><SftpTransferWarning warning={task.warning} compact /></div>
: <span className="shrink-0 font-mono text-muted-foreground">{detail}</span>}
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
@@ -658,6 +661,13 @@ function TransferRow({
</span>
</div>
{task.status === "completed" && task.warning && <div className="mt-1"><SftpTransferWarning warning={task.warning} /></div>}
{childTasks.some((child) => child.status === "completed" && !!child.warning) && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
{t("sftp.transfers.cleanupWarnings", { count: childTasks.filter((child) => child.status === "completed" && !!child.warning).length })}
</p>
)}
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
{isDirParent && !expanded && displayChildren.length > 0 && (
<div
@@ -0,0 +1,98 @@
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 { normalizeVaultOrder, sortByVaultOrder } from "../domain/vaultOrder.ts";
import type { GroupConfig } from "../types.ts";
import GroupDetailsPanel from "./GroupDetailsPanel.tsx";
import { HostNotesEditor } from "./host/HostNotesEditor.tsx";
import { AsidePanel } from "./ui/aside-panel.tsx";
import { Input } from "./ui/input.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
import { installDomEnvironment } from "./test-support/renderReactDom.tsx";
async function saveGroup(config: GroupConfig | undefined, newName?: string) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
const dom = installDomEnvironment();
const previousRaf = globalThis.requestAnimationFrame;
const previousCancelRaf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(0), 0) as unknown as number;
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
environment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let saved: GroupConfig | undefined;
let savedName: string | undefined;
try {
await act(async () => {
renderer = create(
<TooltipProvider>
<GroupDetailsPanel
groupPath={config?.path ?? "new-group"}
config={config}
availableKeys={[]}
identities={[]}
allHosts={[]}
groups={[]}
terminalThemeId="default"
terminalFontSize={14}
onSave={(value, name) => { saved = value; savedName = name; }}
onCancel={() => {}}
/>
</TooltipProvider>,
);
});
await act(async () => {
renderer!.root.findByType(HostNotesEditor).props.onChange(" Updated note ");
if (newName) {
renderer!.root.findAllByType(Input)[0].props.onChange({ target: { value: newName } });
}
});
await act(async () => {
renderer!.root.findByType(AsidePanel).props.actions.props.onClick();
});
assert.ok(saved, "the actual panel save handler must emit a config");
return { saved, savedName };
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
globalThis.requestAnimationFrame = previousRaf;
globalThis.cancelAnimationFrame = previousCancelRaf;
dom.cleanup();
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
test("editing group notes retains its manual position through write normalization", async () => {
const original = { path: "Fleet-0999", order: 1000 };
const sibling = { path: "Fleet-0998", order: 2000 };
const { saved, savedName } = await saveGroup(original);
assert.deepEqual(saved, { ...original, notes: "Updated note" });
assert.equal(savedName, undefined);
// VaultView removes the old config and appends the panel's replacement.
const written = normalizeVaultOrder([sibling, saved]);
assert.deepEqual(sortByVaultOrder(written).map((config) => config.path), [original.path, sibling.path]);
assert.deepEqual(original, { path: "Fleet-0999", order: 1000 });
});
test("renaming a group in the details panel retains its manual position", async () => {
const { saved, savedName } = await saveGroup({ path: "parent/old", order: 2500 }, "renamed");
assert.equal(saved.path, "parent/renamed");
assert.equal(saved.order, 2500);
assert.equal(savedName, "renamed");
});
test("group saves preserve zero and negative manual order values", async () => {
for (const order of [0, -1000]) {
const { saved } = await saveGroup({ path: "group", order });
assert.equal(saved.order, order);
}
});
test("saving a new or unordered group leaves order assignment to the write path", async () => {
for (const config of [undefined, { path: "existing-unordered" }]) {
const { saved } = await saveGroup(config);
assert.equal(Object.hasOwn(saved, "order"), false);
assert.equal(normalizeVaultOrder([saved])[0].order, 1000);
}
});
+2
View File
@@ -559,6 +559,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const result: GroupConfig = {
path: newPath,
// Editing defaults or notes must not change the group's manual position.
...(config?.order !== undefined && { order: config.order }),
notes: form.notes?.trim() || undefined,
// Only include SSH fields if SSH section is enabled
...(sshEnabled && {
+3 -2
View File
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
if (!isVisible) return null;
// Stay mounted when the side panel parks this tree. Returning null here
// would remount the virtual list on every Notes <-> History switch.
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
@@ -394,6 +394,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<VariableSizeVirtualList
ref={listRef}
items={listRows}
measureEnabled={isVisible}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
+1
View File
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { GroupNode } from "../types";
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
const scroller = dom.window.document.getElementById("scroll")!;
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
const { act, createElement } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTreeView } = await import("./HostTreeView.tsx");
const noop = () => undefined;
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
const common = {
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
};
const root = createRoot(dom.window.document.getElementById("root")!);
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
try {
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
const groupConfigs = [{ path: "beta", order: -1 }];
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
} finally {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+72
View File
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
// Deterministic work-count assertions, not a wall-clock benchmark.
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
name: path, path, children: {}, hosts: [],
}));
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
let configVisits = 0;
Object.defineProperty(groupConfigs, "filter", {
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
configVisits += 1;
return predicate(value, index, array);
}),
});
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
configVisits = 0;
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
assert.equal(configVisits, 3);
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
configVisits = 0;
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
? ["group:c", "group:b", "group:a"]
: ["group:a", "group:b", "group:c"]);
}
}
});
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
const parent: GroupNode = {
...leaf("parent"),
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
};
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
const groupConfigs: GroupConfig[] = [
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
{ path: "unordered-a", order: Number.NaN },
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
{ path: "parent/z", order: -2 },
];
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
]);
assert.equal(items[0].kind === "group" && items[0].node, parent);
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
const changed = buildVisibleHostTreeItems({
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
ungroupedHosts, expandedPaths, sortMode: "manual",
});
assert.equal(changed[0].key, "group:tie-a");
});
+18 -14
View File
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
setSize: number;
};
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
groupConfigs: GroupConfig[],
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
const orderByPath = new Map(
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
)
);
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
orderByPath?: ReadonlyMap<string, number>,
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
return [...nodes].sort((a, b) => {
if (sortMode === 'za') return b.name.localeCompare(a.name);
if (sortMode === 'manual') {
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
const orderA = orderByPath?.get(a.path);
const orderB = orderByPath?.get(b.path);
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
if (orderA !== undefined) return -1;
if (orderB !== undefined) return 1;
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
groupConfigs: GroupConfig[];
}): VisibleHostTreeItem[] {
const items: VisibleHostTreeItem[] = [];
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
const visitGroups = (nodes: GroupNode[], depth: number) => {
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
// Root-level ungrouped hosts are siblings of top-level groups.
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const childNodes = useMemo(() => sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
), [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(
() => sortHostTreeHosts(node.hosts, sortMode),
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { Host, Identity } from "../types";
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
const noop = () => {};
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
const dom = installDomEnvironment();
const globals = {
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
localStorage: dom.window.localStorage,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const renderer = await createDomRenderer(dom.document);
try {
for (const scenario of [
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
]) {
await t.test(scenario.name, async () => {
const identity: Identity = {
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
authMethod: "password", password: "fixture-old", created: 0,
};
const identities = scenario.identityUsername === undefined ? [] : [identity];
const originalIdentity = { ...identity };
const host: Host = {
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
password: "fixture-host-old", savePassword: false,
...(identities.length ? { identityId: identity.id } : {}),
};
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
let hosts = [host, otherHost];
let updates = 0;
const request = {
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
allowSavePassword: scenario.allowSavePassword ?? true,
};
let queue = [request];
const responses: unknown[][] = [];
await renderer.render(
<I18nProvider locale="en">
<KeyboardInteractiveModal request={request} onCancel={() => false}
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
hosts, identities, keyboardInteractiveQueue: queue,
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
responses.push(args);
return { success: true };
} }) },
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
}), requestId, values, savePassword)} />
</I18nProvider>,
);
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
assert.ok(input);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
else {
assert.ok(checkbox);
assert.equal(checkbox.checked, false);
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
}
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
assert.ok(submit);
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
assert.deepEqual(queue, []);
const saved = scenario.remember && scenario.allowSavePassword !== false;
assert.equal(updates, saved ? 1 : 0);
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
assert.equal(hosts[0].savePassword, saved);
assert.equal(hosts[1], otherHost);
assert.deepEqual(identity, originalIdentity);
// Exercise the reconnect launcher that builds the actual SSH bridge request.
for (const [index, candidate] of hosts.entries()) {
let started: { username: string; password?: string } | undefined;
const ctx = {
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
sessionId: `reconnect-${index}`, terminalSettings: {},
sessionRef: { current: null }, hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
disposeExitRef: { current: null }, fitAddonRef: { current: null },
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
setProgressValue: noop, setChainProgress: noop,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
writeToSession: noop, resizeSession: noop,
},
};
await createTerminalSessionStarters(ctx as never).startSSH({
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
} as never);
assert.ok(started);
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
}
// A fresh request resets the checkbox and submitting state for the next case.
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
});
}
} finally {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+105 -96
View File
@@ -8,6 +8,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -111,7 +112,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
path: selectedPort.trim(),
baudRate,
dataBits,
stopBits,
@@ -129,7 +130,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort,
hostname: selectedPort.trim(),
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
@@ -157,18 +158,15 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
const isValid = isPortValid && isBaudRateValid;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -212,6 +210,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -219,32 +220,34 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@@ -264,92 +267,98 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="data-bits">
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="stop-bits">
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../application/i18n/I18nProvider';
import type { Host } from '../domain/models';
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
function renderPanel(path: string, hostname = path) {
const host: Host = {
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
port: 115200, username: '', tags: [], os: 'linux',
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
};
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
initialData={host} onSave={() => {}} onCancel={() => {}}
/></I18nProvider>);
}
test('saved named-pipe host is editable without hardware baud controls', () => {
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
assert.match(html, /VM must create the pipe as a server/);
assert.doesNotMatch(html, /for="baud-rate"/);
assert.doesNotMatch(html, /text-destructive/);
});
test('hardware serial host retains its baud-rate controls', () => {
assert.match(renderPanel('COM3'), /for="baud-rate"/);
});
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
assert.doesNotMatch(html, /for="baud-rate"/);
});
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more