Compare commits

...
183 Commits
Author SHA1 Message Date
陈大猫 83e65471b3 fix Mosh shortcuts, highlighting, and scrollback (#2109)
* fix Mosh shortcuts highlighting and scrollback

* restore terminal modes after Mosh exits
2026-07-11 03:45:49 +08:00
陈大猫 09cd907b85 fix(terminal): restore workspace pane X to standalone tab (#2106)
Workspace toolbar X was calling closeSession and destroying the pane
instead of returning it as its own tab. Route that control through
detach when available, harden close/detach against stale session state,
and record the workspace id in tabOrder when drag-to-split creates a
workspace so later detach can put tabs back correctly.
2026-07-11 02:21:13 +08:00
陈大猫 33f37b5f53 fix(terminal): restore split geometry when viewMode is unset (#2103)
#2096 gated split layout on viewMode === "split", but drag-to-split
workspaces from createWorkspaceFromSessions leave viewMode undefined
(default tiled split). Visible panes then fell back to full-size stacking
and looked like a broken split. Treat any non-focus workspace as split
geometry, matching pre-2096 isSplitViewVisible behavior.
2026-07-11 01:47:42 +08:00
陈大猫 ebcc82a2f4 Update Nix release metadata for v1.1.62 (#2102)
Refresh AppImage SRI hashes for the re-published v1.1.62 release assets
(including the SFTP opaque-background fix). Release workflow cannot push
directly because main requires a pull request.
2026-07-11 01:35:11 +08:00
陈大猫 32221dce0b fix(sftp): give SFTP root an opaque theme background (#2101)
After continuous terminal rendering (#2096), inactive terminal panes stay
visible under other surfaces. SFTP had no solid background, so terminal
content bled through when switching from a terminal tab. Use bg-background
so the layer is opaque and still follows the active theme.
2026-07-11 01:15:57 +08:00
陈大猫 fad16446cd Update Nix release metadata for v1.1.62 (#2100)
Mirror the published AppImage SRI hashes from the v1.1.62 release so Nix packaging tracks the new version. The release workflow could not push this update directly because main requires changes through a pull request.
2026-07-11 00:44:19 +08:00
陈大猫 a627ec56c9 Limit macOS icon fix to runtime (#2099) 2026-07-10 23:54:51 +08:00
陈大猫 1a426c4d07 Fix macOS Dock icon sizing (#2098) 2026-07-10 23:40:00 +08:00
陈大猫 1e93f91c62 fix(terminal): open side panel tooltips below to avoid macOS traffic lights (#2097)
When the terminal side panel is docked on the left, default top tooltips
for the tab rail (SFTP, scripts, etc.) rendered under the traffic lights.
Match the host-tree toolbar and force side="bottom" (#2095).
2026-07-10 23:31:58 +08:00
陈大猫 76442e3240 Keep terminal panes continuously rendered unless hibernation is enabled (#2096)
* Keep terminals rendering unless hibernation is enabled

* Keep background terminals live through setting changes

* Close terminal hibernation races

* Resume cancelled hibernation upgrades

* Keep inactive terminal overlays below the active pane

* Remove non-hibernation repaint compensation

* Revert "Remove non-hibernation repaint compensation"

This reverts commit e88089c030.

* Keep terminal recovery safeguards

* Preserve inactive terminal dimensions

* Keep hidden terminal layouts stable

* Initialize hidden terminal layout once

* Keep inactive terminal frames stable

* Freeze cold-start terminal dimensions

* Keep hidden workspace presentation stable

* Track latest visible terminal size
2026-07-10 23:04:47 +08:00
陈大猫 01e7edc006 feat(mosh): integrate MoshCatty pure Rust client (no Cygwin legacy) (#2085)
* feat(mosh): default bundled client to MoshCatty releases

Wire Netcatty packaging and npm run dev to pull pure Rust mosh-client
binaries from binaricat/MoshCatty (moshcatty-* tags), document the cutover,
and mark the old Cygwin Windows fetch path as legacy-only.

* fix(mosh): accept pure MoshCatty packages (exe-only, no dlls/terminfo)

Align fetch/packaging with MoshCatty pure-Rust releases: require only
mosh-client[.exe], treat Cygwin dll bags and terminfo as optional legacy
layout. Document moshcatty-0.1.1 (ConPTY Ctrl+C + static MSVC CRT).

* refactor(mosh): drop Cygwin legacy; MoshCatty-only pipeline

Remove FluentTerminal/mosh-bin fallbacks, build-mosh C++ CI, and
dll/terminfo runtime helpers. Fetch accepts only moshcatty-* pure
binaries; packaging ships a single mosh-client per platform.

* docs: align ET checklist and et README with MoshCatty-only mosh

Update mosh template references after removing Cygwin build pipeline and
legacy fallbacks. Point ET packaging notes at fetch-mosh / MoshCatty
instead of deleted build-mosh-binaries workflow.

* docs(mosh): require MoshCatty 0.1.2+ for Linux glibc floors

Codex review vs main: defaulting to MoshCatty Linux binaries built on
ubuntu-latest required GLIBC 2.34, above Netcatty's package floors
(x64 2.28 / arm64 2.31). Document the floors, prefer moshcatty-0.1.2+,
and drop trailing whitespace in the design note. Upstream MoshCatty
release CI now builds in almalinux:8 / debian:bullseye with assert.

* fix(mosh): reject MoshCatty releases below 0.1.2

Codex re-review: docs alone still allowed MOSH_BIN_RELEASE=moshcatty-0.1.0/0.1.1,
which produce packages that fail to start on Netcatty's Linux glibc floors.
Validate the minimum tag in resolve and fetch, skip pre-0.1.2 when picking
latest, and cover the reject path in unit tests.

* fix(mosh): pin MoshCatty owner and reject floor prereleases

Codex review vs main (P2):
- Always default MOSH_BIN owner/repo to binaricat/MoshCatty; do not
  inherit fork owner from GITHUB_REPOSITORY in resolve or fetch.
- Treat moshcatty-X.Y.Z-rcN as below final X.Y.Z so pins like
  moshcatty-0.1.2-rc1 cannot bypass the glibc floor.

* fix(mosh): accept build metadata in MoshCatty release tags

TAG_RE rejected '+' so moshcatty-0.1.2+build.1 failed validation even
though the version parser and isAtLeastMinRelease accepted it. Align
the regex and require a successful semver parse.
2026-07-10 20:24:24 +08:00
陈大猫 46c4be135f Fix MobaXterm host imports (#2094) 2026-07-10 18:51:58 +08:00
陈大猫 15d654f785 Measure actual SSH network latency (#2081)
* Clarify SSH stats response time label

* Cover all SSH stats response labels

* Measure SSH network round-trip latency

* Harden SSH latency probe
2026-07-10 18:48:54 +08:00
陈大猫 2b13673512 Preserve remote locale for SSH sessions (#2091) 2026-07-10 18:48:39 +08:00
陈大猫 3ea5f295d2 Fix SFTP upload close false failure (#2092) 2026-07-10 18:43:38 +08:00
陈大猫 897f7c901f Fix System to Dark theme race (#2083)
* Fix system-to-dark theme race

* Cover peer windows in theme race fix

* Exercise production theme sync path

* fix(settings): announce all changed appearance fields over IPC

Theme sync only broadcast STORAGE_KEY_THEME, so receivers that hit the
stale-storage race for UI theme or accent kept the old value. Emit a
keyed notify for each appearance field that actually changed so the
reducer race guard covers light/dark UI theme ids and accent state too.

* fix(settings): keep current appearance fields across keyed IPC

Sequential notifies for one multi-field appearance change were
re-reading non-announced keys from still-stale storage, so a later
UI-theme IPC could revert an earlier theme update. Preserve in-memory
current values for non-matching keys, compose IPC applies via a ref,
and cover the multi-notify sequence in tests.
2026-07-10 17:50:42 +08:00
zjkal 3085bfdfce Handle main window zoom shortcuts explicitly (#2084) 2026-07-10 15:27:58 +08:00
陈大猫 8d781af4f0 Fix OpenWrt process listing (#2082)
* Fix OpenWrt process listing

* Strengthen BusyBox process fallback tests
2026-07-10 10:43:10 +08:00
陈大猫 9f2669645b Update Nix release metadata for v1.1.61 (#2071) 2026-07-10 01:44:45 +08:00
陈大猫 70b5ab2bd6 fix(ci): build Linux x64 packages on AlmaLinux 8
Build Linux x64 native modules in an AlmaLinux 8 environment with a newer toolchain while keeping the RHEL 8 compatibility target. Stabilize RPM and pacman packaging in that container, and harden bundled mosh/ET binary builds against transient dependency download failures.
2026-07-10 01:21:19 +08:00
陈大猫 753d6b7aa4 fix(terminal): keep bulk drain batches while SSH flow is paused (#1961) (#2068)
After the 1MB high-water mark fix, bulk dumps like `tail -2000f` still slowed
down because pause/flood mode shrank the coalescer to 8KB batches. That made
the renderer drain the 1MB backlog slowly, so each pause/resume RTT stayed
long and total time regressed toward ~10s on WAN links.

Keep the full bulk coalesce size while flow is paused so the backlog drains
fast enough to resume the SSH channel quickly; only use the flood cap when the
write queue is flooded but flow is still open. Also raise flood coalesce to
128KB and per-turn queue drain to 256KB so plain-text floods do not shard into
tiny setTimeout yields.
2026-07-09 22:45:12 +08:00
陈大猫 a4b5204c74 fix(settings): surface External MCP permission mode and collapse help into tooltip (#2067)
Show the active write permission mode as a dedicated status row, and move the
lengthy how-to / security copy into a header help tooltip so the card stays
scannable. Also fix usage copy that incorrectly said the toggle was "below".
2026-07-09 22:20:06 +08:00
陈大猫 64ff394847 feat(ai): add host_open for MCP agents and polish External MCP setup UI (#2066)
Agents could list vault hosts and run commands on open sessions, but could not
open a host themselves. Add vault.host.open (MCP: host_open) that opens a
terminal tab via the vault agent bridge, merges the new session into MCP scope,
and documents the flow in the Catty system prompt.

Also redesign External MCP client configuration into segmented tabs with
shared copyable code blocks so setup matches the settings design system.
2026-07-09 21:58:20 +08:00
陈大猫 ae52ef461e fix(ai): probe remote login shell so fish AI exec works (#1854) (#2061)
* fix(ai): probe remote login shell before PTY wrappers (#1854)

SSH/Telnet sessions never set shellKind, so AI exec defaulted to the
posix wrapper and broke fish login shells with "Unsupported use of '='".

Probe the remote login shell once via a silent SSH exec channel before
AI/MCP PTY execution, cache session.shellKind, and leave et/mosh
shellKind unset so they can take the same path. Fish wrapper coverage
includes a real-fish binary regression test.

* fix(ai): harden remote shell kind probing

* fix(ai): keep PowerShell prompt override after posix login probe

Do not pin probed posix login shells onto session.shellKind. Unset still
defaults to posix wrapping, while leaving the kind open preserves the
live PS ...> prompt override for bash/zsh login shells that entered
pwsh interactively (Codex P2 on #2061 / issue #841 path).

* fix(ai): make remote shellKind probe cancellable before PTY write

Register a pending activePtyExecs marker for the pre-exec shell probe so
Stop during the probe window latches cancellation and does not still type
the command after the probe resolves (Codex P2 on #2061). Wire through
Catty, MCP, and worker foreground exec paths.

* fix(ai): release session lock if job shell probe rejects

terminal_start reserves the session before ensureSessionShellKind; if that
promise rejects, the previous .then-only path never released the lock.

* fix(ai): cancel pending terminal_start during shellKind probe

Register background job entries before the remote shell probe so chat
cancel can latch and abort job start before startPtyJob types into the
PTY (MCP + worker paths; Codex P2 on #2061).

* fix(ai): do not pin login shell as active; default to posix_sh

Login-shell probe no longer sets session.shellKind for fish/posix — only
powershell/cmd are pinned. Unset shellKind resolves to posix_sh (sh -c
inner POSIX wrapper), which both fish and bash interactive shells can
parse, fixing #1854 without assuming login shell === active shell
(Codex P2 on #2061).

* fix(ai): use login shell as soft hint, not sh -c / dash

Drop posix_sh default that ran commands under /bin/sh (often dash) and
broke bash/zsh source, [[ ]], and functions. Keep fish/posix probe as
session._loginShellKind soft hint for resolveEffectiveShellKind so fish
login still gets the fish wrapper and bash/zsh keep the native posix
wrapper, while live PS prompts can still override.

* test(ai): cover shell probe cancellation gaps
2026-07-09 20:56:38 +08:00
陈大猫 b6cdd602f1 fix: build Linux x64 packages on Debian Buster for glibc 2.28 (#2063)
* fix: build Linux x64 packages on Debian Buster for glibc 2.28

Rebuild node-pty (and serialport) inside debian:buster so x64 releases
load on RHEL 8 / CentOS 8 / UOS / Deepin instead of requiring glibc 2.35
from ubuntu-22.04. Mirrors the arm64 containerized build pattern (#2062).

* fix: run Buster build-deps step with bash

Container jobs default to /bin/sh; dash does not support [[ so the
archive.debian.org rewrite would be skipped and apt-get update would fail.

* fix: disable apt Valid-Until for archived Buster mirrors

archive.debian.org security metadata for Buster is expired; without
Acquire::Check-Valid-Until false, apt-get update fails in the container.

* fix: install Python 3.11 for node-gyp on Buster x64 builds

Buster ships Python 3.7; node-gyp 12 uses walrus syntax and fails during
npm ci when rebuilding node-pty. Keep the glibc 2.28 container and supply
a modern interpreter via actions/setup-python.

* fix: install Python 3.11 via uv inside Buster x64 builds

actions/setup-python is not reliable on glibc 2.28 containers. Install a
manylinux CPython with uv so node-gyp 12 can rebuild node-pty during npm ci.
2026-07-09 19:35:11 +08:00
陈大猫 4735e9ace0 fix: bastion script waits and keyboard-like sendLine (#1960) (#2055)
Keep only the confirmed product fixes for 奇安信-style auto-login:

1. Seed startup screen from viewport or live buffer so on-screen menus
   stay waitable (never mark the whole buffer consumed at script start).
2. sendLine emits body then CR separately, matching real keyboard input.
3. After sendLine, consume only pre-send buffer length so peer prompts
   that arrive between body and Enter remain matchable for the next step.
2026-07-09 17:58:18 +08:00
陈大猫 ca0c59ff0c Merge pull request #2059 from binaricat/codex/fix-script-stop-rerun-2052
Fix rerunning stopped automation scripts
2026-07-09 17:20:28 +08:00
bincxz 2f3ba0e581 Scope script log cleanup to each run 2026-07-09 17:15:18 +08:00
bincxz cf34c14f3a Ignore stale script actions after completion 2026-07-09 16:58:15 +08:00
bincxz f696163674 Update lockfile for CI npm install 2026-07-09 16:47:33 +08:00
bincxz b18c3e8c4f Merge remote-tracking branch 'origin/main' into codex/fix-script-stop-rerun-2052 2026-07-09 16:41:12 +08:00
bincxz e658d008dc Clean up script logs after stopping runs 2026-07-09 16:39:36 +08:00
陈大猫 a82b006428 Merge pull request #2045 from binaricat/feature/sftp-connected-hosts-picker
feat: show live terminal hosts in SFTP picker
2026-07-09 16:38:51 +08:00
bincxzandCursor 78554e6d37 fix: compare SFTP picker sessions positionally for memoization
Same-host Connected preference depends on session order; ignoring
reorder left the picker bound to a stale sourceSessionId.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:27:34 +08:00
bincxz 0c7f69dc8f Fix rerunning stopped automation scripts 2026-07-09 16:27:23 +08:00
陈大猫 45a38237ad Merge pull request #2047 from binaricat/cursor/c95e2aa1
feat: External MCP for Codex, Claude, Cursor, and Grok
2026-07-09 16:24:29 +08:00
陈大猫 74eeecb190 Merge pull request #2058 from binaricat/codex/fix-jump-auth-error-attribution
Fix jump host auth failure attribution
2026-07-09 16:24:09 +08:00
bincxzandCursor 584ccf9482 fix: skip endpoint match for reuseOnly SFTP Connected probes
Connected picker names a specific live session; renderer username/port
can lag the authenticated bridge endpoint, so reuseOnly must not reject
on endpoint mismatch before attaching to that session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:21:44 +08:00
bincxz 21350d64db Fix jump host auth failure attribution 2026-07-09 16:15:59 +08:00
bincxzandCursor 04810ae15e fix: default Connected picker port from live session, not vault
When session.port is undefined, use SSH default 22 instead of a vault
host.port that may have been edited after the terminal connected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:12:20 +08:00
陈大猫 729958ea90 Merge pull request #2056 from binaricat/codex/fix-2051-group-search
Fix group host list search scoping
2026-07-09 16:10:52 +08:00
陈大猫 c89e3d9574 Merge pull request #2057 from binaricat/codex/2054-quick-switch-powershell-setting
Fix quick switcher local shell setting
2026-07-09 16:10:28 +08:00
bincxzandCursor fa445a8466 fix: avoid double-issuing External MCP token on host-ready
Host-ready only refreshes an already-issued external token so cold-start enable cannot write a short-lived discovery token before resolveHostCredentials rotates it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:09:26 +08:00
bincxz b2d4883006 fix: respect local shell setting in quick switcher 2026-07-09 16:05:11 +08:00
bincxzandCursor 9848107592 fix: make SFTP live reuse probe fail closed with reuseOnly
Endpoint-only Connected picker attempts must not fall through to a
fresh SSH dial. Also correct zh-CN/zh-TW Connected section labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:02:20 +08:00
bincxzandCursor 9a45005406 fix: keep External discovery on dedicated token after host-ready
Ignore Catty/CLI authToken from onBridgeHostReady and re-assert the cancel flag after scoped metadata cleanup so soft-revoke stays intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:02:20 +08:00
陈大猫 e06b47625f Merge pull request #2050 from binaricat/feature/http-proxy-settings
Add app-level HTTP(S) proxy for cloud sync and AI
2026-07-09 16:00:21 +08:00
bincxzandCursor 609c664eed fix: reuse live SFTP sessions without vault credentials
Try endpoint-only openSftp with sourceSessionId before getHostCredentials
so Connected picker rows work when saved password/key is missing or
undecryptable on this device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:55:06 +08:00
bincxz 37a136aeb5 Fix group host list search scoping 2026-07-09 15:54:07 +08:00
bincxzandCursor 1ce4797bdb fix: use dedicated External MCP auth token and stop reseeding cleared scope
Issue a rotatable external-only TCP token on enable, soft-revoke it on disable without killing long-lived stdio sockets, force external sockets onto the reserved chat scope, and only seed empty external metadata when the scope key was never written.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:52:07 +08:00
bincxzandCursor d8aacbe4e9 Harden HTTP proxy cancel, TLS, and env isolation.
Register stream cancel before proxy lookup, keep abort as AbortError end-to-end, avoid weakening proxy-hop TLS, strip scheme-less credentials, and restore launch-time proxy env for terminals and SSH ProxyCommand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:42:31 +08:00
bincxzandCursor 9c66e7f309 fix: allow Settings External MCP approvals and move session sync to application state
Accept approval responses from Settings, clear sibling approval cards on resolve, register Always Allow grant persisters outside Catty, and keep session sync behind the application/state lint boundary.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:39:17 +08:00
bincxzandCursor 8020d45808 fix: gate External MCP scope writes and surface approvals in Settings
Reject disabled/in-flight scope resurrection, move session sync to App mount, cancel the external chat session on stop, and broadcast approval cards to main + settings windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:31:38 +08:00
bincxzandCursor 48157f56a8 fix: keep edited saved hosts visible next to Connected rows
Only hide a vault host when its endpoint matches the live Connected
entry, so post-connect host edits still offer the new saved target.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:16:03 +08:00
bincxzandCursor dbe3e693e7 fix: harden External MCP session sync and approval bridge lifetime
Skip peer/settings windows, listen for cross-window enable, only mark payloads sent after success, and mount the MCP approval IPC bridge at app startup before TerminalLayer lazy-loads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:13:37 +08:00
bincxzandCursor fc6a686ad9 fix: exclude SFTP sudo hosts from Connected picker
Sudo SFTP never reuses a terminal shell connection; advertising those
hosts as Live would fall back to a fresh auth while hiding the normal row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:05:58 +08:00
bincxzandCursor da1e9547ec fix: use live session endpoint for SFTP Connected picker rows
Vault host edits after connect must not break sourceSessionId reuse;
overlay hostname/username/port from the terminal session onto the picker host.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:00:00 +08:00
bincxzandCursor f615fc4b89 fix: make External MCP usable without Catty panel and harden enable races
Serialize enable/disable, cancel in-flight external work on stop, refresh discovery env for live permission mode, sync all terminals from TerminalLayer, host confirm approvals globally, and treat Claude local/project scope as upgradeable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:49:40 +08:00
bincxzandCursor 5c0e5f5538 Fix proxy settings equality and proxied insecure TLS.
Skip rebroadcast when HTTP proxy settings are unchanged across windows, and force rejectUnauthorized onto the tunneled target TLS hop when allowInsecure is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:28:59 +08:00
bincxzandCursor c0d90178ab fix: merge External MCP session scope and refresh permission mode live
Keep app-wide external hosts when a Catty sidebar push arrives, and re-read discovery permissionMode so long-lived stdio clients pick up Safety changes without restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:27:51 +08:00
bincxzandCursor f291ba5ebf fix: decide SFTP Connected reuse from session transport
Drop the vault-host mosh/et filter so plain SSH terminals opened against
a mosh/et-default host (e.g. ssh:// deep links) still appear as reusable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:18:34 +08:00
bincxzandCursor 5e16c72c03 fix: seed External MCP scope from renderer/Catty metadata
In terminal-worker mode mirror renderer session payloads into
__external_mcp__ and seed from other chat scopes when the live map is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:16:23 +08:00
bincxzandCursor c02848bb08 Add app-level HTTP(S) proxy for cloud sync and AI.
Lets Windows users (and others) route Google Drive / OneDrive / GitHub sync and AI provider traffic through system or custom proxies without touching SSH ProxyJump settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:12:08 +08:00
bincxzandCursor f67a29ecd7 fix: keep External MCP scope when live session map is empty
In terminal-worker mode the main-process sessions map can be empty; avoid
overwriting renderer-pushed __external_mcp__ metadata with an empty sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:09:31 +08:00
bincxzandCursor 793f86129c fix: preserve External MCP session metadata and Claude env parsing
Merge prior scoped host metadata when syncing live sessions, and parse
Claude Environment: KEY=VALUE blocks so configured entries are not
misclassified as missing discovery env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:05:32 +08:00
bincxzandCursor 9811e1ad24 fix: keep Grok extra-arg launcher entries as conflict
Do not fall back to the expected launcher path when the existing Grok
entry carries args; that masked conflicts as configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:59:14 +08:00
bincxzandCursor 1840fa53f8 fix: remove disabled Codex/Grok External MCP entries before re-add
Keep disabled same-name entries visible to classifiers so one-click setup
can mcp remove them before writing the discovery-env launcher config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:58:59 +08:00
bincxzandCursor 416ea21424 fix: revoke External MCP approvals and parse Claude scope text
Clear pending reserved-scope approvals on disable, recognize Claude's
"User/Local config (...)" scope lines, and ignore disabled Grok entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:49:53 +08:00
bincxzandCursor 82af11f26e fix: only list reusable SSH sessions in SFTP Connected picker
Exclude connecting and Mosh/ET sessions so Connected rows always map
to a live ssh2 shell that SFTP can reuse without a fresh login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:49:00 +08:00
bincxzandCursor b04e8f58bf fix: clear Claude local-scope External MCP entries on upgrade
Parse Scope/Args from `claude mcp get`, remove the blocking scope before
re-adding with discovery env, and treat non-empty Args as a conflict.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:39:58 +08:00
bincxzandCursor 54e4d8dfbb fix: prefer latest same-host session for SFTP reuse
When multiple live terminals share a hostId, pick the later same-status
session so Connected picker reuse targets the newest endpoint match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:36:07 +08:00
bincxzandCursor 45d7046b16 fix: require discovery env for External MCP client configured state
Treat launcher-only Codex/Claude/Grok entries as not_configured so one-click
setup can remove and re-add them with NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:34:23 +08:00
陈大猫 ae8babcf5a Merge pull request #2048 from keyleaf/fix-buffer-early-ai-stream-events
fix(ai): buffer early bridge stream events
2026-07-09 13:32:14 +08:00
bincxzandCursor 9b2b8be2b8 fix: surface External MCP approvals and harden discovery resolve
Always show reserved-scope approval cards in Catty, stop rewriting
discovery after startup errors, and fail closed when an explicit discovery
env path is missing instead of falling back to another profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:30:43 +08:00
bincxzandCursor b6dec0dba3 fix: correct Claude get scope and External MCP enable gate
Drop unsupported `-s` from `claude mcp get`, treat only starting/running as
enabled for RPC gates, and reject launcher commands that carry extra args.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:22:39 +08:00
bincxzandCursor 5ba34b647e fix: tighten External MCP revoke, Claude user scope, and polling
Disconnect marked external sockets on disable, await in-flight stop before
re-enable, write Claude MCP entries to user scope, and stop spawning client
CLIs on the quiet status poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:12:03 +08:00
keyleaf 73bc62948f fix(ai): buffer early bridge stream events 2026-07-09 13:06:47 +08:00
bincxzandCursor 524db08a8e fix: harden External MCP discovery path and disable revoke
Inject discovery file env into one-click client setup, resolve launcher
paths across Electron userData variants, reject reserved-scope RPCs when
disabled, and avoid peer-window startup clearing temporary mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:02:53 +08:00
bincxzandCursor 7d8f46d00d feat: productize External MCP for Codex, Claude, Cursor, and Grok
Expose the existing catalog MCP as a Settings-managed local bridge with
discovery lifecycle, one-click client setup, and reserved external scope
so third-party agents can use Netcatty without a sidebar chat session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:46:34 +08:00
bincxzandCursor 387866cb71 fix: ignore unrelated session churn in SFTP view memo
Use sftpPickerSessionsEqual in SftpView and SftpSidePanel memo
comparisons so OSC title updates do not force full SFTP re-renders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:32:31 +08:00
bincxzandCursor 4d72c80189 fix: compare only SFTP-relevant session fields for side panel
Avoid re-rendering mounted side panels on OSC title churn by comparing
id/hostId/protocol/status for the Connected picker, and ignore local
.omo run-continuation state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:28:30 +08:00
bincxzandCursor 317c05f9f4 fix: refresh SFTP Connected picker when sessions change
Include sessions in the side-panel stable context equality check so the
Connected host list updates when terminal sessions connect or change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:14:21 +08:00
陈大猫 a6141ebb33 Merge pull request #2046 from binaricat/feat/finer-window-opacity-2019
feat(settings): finer window opacity steps (96–99%)
2026-07-09 12:09:15 +08:00
bincxzandCursor 8ccf46c8f8 fix(settings): match title-bar opacity slider to 1% steps
Keep WindowOpacityButton in sync with Settings so 96–99% stays
selectable from the top-tabs control as well.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:02:49 +08:00
bincxzandCursor 07c7a0fae5 feat(settings): allow 1% window opacity steps for finer control
The appearance slider previously stepped by 5%, so 96–99% was unreachable
even though the clamp already allows any value in [0.5, 1]. Fixes #2019.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:58:37 +08:00
bincxzandCursor a90a681cf0 feat: show live terminal hosts in SFTP picker
Let users pick currently connected SSH sessions from the SFTP host
picker and reuse the existing connection, including ephemeral deep-link
hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:52:38 +08:00
陈大猫 26a923c498 Merge pull request #2044 from binaricat/fix/2037-local-terminal-history-autocomplete
fix(terminal): seed Local Terminal autocomplete from shell histfiles
2026-07-09 11:27:19 +08:00
bincxzandCursor b688fc815b fix(terminal): seed Local Terminal autocomplete from shell histfiles
Local Terminal used a per-session hostId, so autocomplete history never
accumulated, and the store never imported ~/.zsh_history. Stabilize the
hostId and seed from local histfiles so prefix suggestions match Ghostty.

Closes #2037

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:16:41 +08:00
陈大猫 93421abdc4 Merge pull request #2043 from binaricat/cursor/8968f247
fix: accept whitespace-only SSH passwords
2026-07-09 10:59:58 +08:00
bincxzandCursor a5e13009f9 fix: accept whitespace-only SSH passwords
Do not trim password values when validating auth forms or attaching
credentials to SSH connect options, so a single-space password works
(issue #2036).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:52:54 +08:00
陈大猫 626b5a2c04 Merge pull request #2041 from binaricat/fix/1985-terminal-visibility-memo
fix(terminal): keep tab visibility in sync for output recovery
2026-07-09 10:41:09 +08:00
bincxzandCursor 26a550c596 fix(terminal): wake hidden panes when hibernate is disabled
Closing the hibernate setting must restore already soft-hidden or
hibernated tabs immediately, instead of waiting until the user selects
them again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:27:36 +08:00
bincxzandCursor 6dd816e346 fix(terminal): keep tab visibility in sync for output recovery
Solo tab switches only flip isVisible. Skipping that prop in Terminal's
memo left isVisibleRef stale, so hidden panes kept the visible write path
and #1995/#1996 tab-return recovery never ran (#1985).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:21:50 +08:00
陈大猫 3ff093f576 Merge pull request #2034 from binaricat/fix/2010-sudo-prompt-interrupt-drain
fix: keep sudo password prompts during Ctrl+C drain (#2010)
2026-07-09 10:14:34 +08:00
bincxzandCursor f18be6e8a9 fix: accept last-line password prompts in large drain chunks
Detect complete password prompts on the last line independently of the
whole-chunk promptCandidateBytes cap, so a large stale prefix plus a
sudo/password prompt is not dropped during Ctrl+C drain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:11:02 +08:00
陈大猫 882b37c6e6 Merge pull request #2038 from binaricat/cursor/c10b1971
Tighten spam filter to delete any untrusted zip comments
2026-07-09 10:06:33 +08:00
bincxzandCursor 52f92330ad Tighten spam filter to delete any untrusted zip comments
Attackers switched to hash-named zips with soft bait, so drop the
scoring heuristics and treat any zip/dangerous attachment from
non-maintainers as spam.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:06:22 +08:00
陈大猫andCursor 1428e9754c fix: let script waits match the visible startup screen (#2035)
* fix: let script waits match the visible startup screen

Auto-login scripts that wait for bastion menus already on screen were
timing out because startup snapshot sync marked the whole buffer as
consumed. Replace the script buffer with the current viewport so waits
can match visible text while still ignoring scrolled-off scrollback.

Fixes #1960

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep startup viewport waitable across sync races

Address Codex review on #2035: merge the visible snapshot with any
output that arrives during the snapshot IPC (e.g. bastion BEL), and seed
the whole viewport as fresh so waitFor* can match headers beyond the
512-byte tail slack on long menus.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: do not seed buffer-fallback scrollback as visible screen

When the renderer misses the snapshot request, requestScreenSnapshot
returns the full script buffer, not the viewport. Mark that path and
keep the consumed baseline so scrolled-off keywords cannot rematch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: preserve live output during fallback snapshot sync

Baseline only the pre-sync buffer length on empty/fallback snapshots so
bytes that arrive while waiting for the snapshot stay matchable, matching
the viewport path's trailingFresh behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep waitForPrompt scoped to the live tail after seeding

Seeded full-viewport freshness is for waitFor/waitForText/waitForRegex
only. waitForAny still uses the 512-byte tail window so an older prompt
still visible above long-running output cannot satisfy waitForPrompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: advance scan offset when consuming a preserved startup prompt

waitForPrompt could resolve a seeded live-tail prompt without consuming
the viewport above it, so a later waitForText rematched older visible
lines. Advance scanOffset through the preserved prompt end.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: consume the full seeded viewport after startup prompt

When waitForPrompt resolves a preserved live-tail prompt, advance
scanOffset through the entire seeded snapshot — not just the prompt —
so later waitForText cannot rematch older visible text after the prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep sync-race trailingFresh outside seeded viewport

seededLength must end at the visible snapshot so waitForPrompt does not
consume bytes that arrived during the snapshot IPC. Those trailingFresh
bytes stay matchable for later waitForText/waitForRegex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: dedupe trailingFresh against blank-padded viewport snapshots

Full-viewport snapshots often end with blank rows after live content, so
a plain endsWith check missed sync-race duplicates. Compare after
stripping trailing blank lines before appending trailingFresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: let generic waitForAny match the full seeded viewport

Tail-only freshness is for waitForPrompt (allowPreservedTailMatch).
Generic waitForAny must still see menu labels near the top of a long
seeded startup screen, matching waitForText/waitForRegex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: trim partial overlap from sync-race trailingFresh

When the viewport snapshot already contains a prefix of trailingFresh,
append only the non-overlapping suffix so waitForText cannot rematch a
phantom duplicate of the overlapped text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: match partial trailingFresh overlap against full viewport

Use the unstripped viewport when trimming sync-race prefixes so an
overlapped trailing newline is not left behind as a phantom blank line.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep waitForPrompt from rematching short seeded prompts

After live output clears preservedTailMatch, currentTailFreshBoundary
must start at seededLength so a short startup prompt cannot satisfy
waitForPrompt through the normal 512-byte window.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: invalidate preserved prompts once scanOffset passes them

If waitForText/waitForRegex already consumed output after a preserved
startup prompt, clear that prompt via advanceScanOffset so waitForPrompt
cannot reuse it. markOutputConsumedThrough still keeps the one-shot
startup prompt for waitForPrompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: trim blank-padded overlap without dropping duplicate sync markers

Use syncStartText to keep genuinely new duplicate suffixes during snapshot
sync, while still trimming blank-padded and partial overlaps from trailingFresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: treat scrollback-backed stale viewports as pre-sync

When syncStart has scrollback above the visible suffix, a stale snapshot can
still equal that suffix without matching syncStart exactly — keep trailingFresh
so duplicate markers printed during the IPC round-trip are not trimmed away.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: invalidate startup seed after automated script input

sendLine/send must not leave the seeded viewport or preserved prompt
matchable, or common send-then-wait scripts can complete against pre-command
output. Keep sync-race trailingFresh beyond seededLength still waitable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep long sync-race output waitable after startup prompt

After waitForPrompt consumes the preserved prompt, re-seed remaining
trailingFresh so markers ahead of a >512-byte burst stay matchable. Also
consume all buffered bytes on automated input, including pre-input trailingFresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:57:59 +08:00
bincxzandCursor c41d11989b fix: strip trailing restore sequences from password holds
When multiple restore controls are preserved, peel complete leading
restore sequences that are a suffix of preserved from the password
prefix so droppedBytes stay accurate and restores are not re-emitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:22:47 +08:00
bincxzandCursor 17b33c192e fix: drop lone incomplete SGR CSI during interrupt drain
Hold incomplete CSI with password prefixes, but drop standalone SGR
fragments so they cannot leak into a later shell prompt. Restore/OSC
prefixes remain holdable alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:18:35 +08:00
bincxzandCursor 9837f4fed9 fix: tighten restore overlap and mid-CSI discard paths
Only strip a full preserved restore prefix from password holds, and when
discarding a mid-CSI held prefix also consume the CSI final byte so
shell prompts are not corrupted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:15:07 +08:00
bincxzandCursor 30104338d2 fix: hold incomplete CSI params with password prefixes
Trailing-control recognition now covers incomplete CSI parameter
bytes (ESC[0 / ESC[31), so styled password prompts split mid-SGR still
survive Ctrl+C drain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:11:11 +08:00
bincxzandCursor 86d4897a37 fix: exclude preserved restore from held password prefixes
When a drain chunk preserves alternate-screen restore and also holds a
password prefix on the same line, strip the overlapping restore from
pending so droppedBytes stay accurate and restore is not re-emitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:07:10 +08:00
bincxzandCursor 4180a770e5 fix: keep password prefixes when drain holds trailing ANSI
Styled prompts can split mid-CSI (Pass + ESC[ / 0mword:). Hold both
the password-prefix body and the trailing control prefix so the prompt
still completes across chunks during Ctrl+C drain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:00:08 +08:00
bincxzandCursor ef70cad34d fix: add password-prompt to interrupt filter reason union
Include the new resume reason in TerminalInterruptDisplayFilterReason
so typed returns from the one-chunk password path type-check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:52:16 +08:00
bincxzandCursor 2b7f4b47d7 fix: accept one-chunk password prompts before quiet gap
Password prompts often emit nothing further until the user types, so
dropping a complete prompt while waiting for promptQuietMs leaves a
blank terminal. Resume immediately on complete password candidates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:46:23 +08:00
bincxzandCursor 885ab7b379 fix: hold ANSI-colored split password prompts during drain
Match password-prefix candidates after stripAnsi so SGR-styled chunks
like ESC[31mPass still hold across chunks, while keeping raw pending
bytes for display when the prompt completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:36:24 +08:00
bincxzandCursor 90e7149517 fix: keep ASCII minLen for password: keyword targets
Classify keyword bodies without punctuation/spaces so full-width
colon targets do not collapse minLen to 1 and hold lone "p"/"pa".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:29:50 +08:00
bincxzandCursor fd2d34f780 fix: reject held password-prefix completion across line breaks
A held Pass prefix followed by \nPassword: is a fresh flood line, not a
same-line completion. Discard the prefix and keep quiet-gap so stale
prompts cannot bypass drain via the held-prefix resume path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:22:13 +08:00
bincxzandCursor 317d5647e0 fix: keep quiet-gap for fresh password-looking drain lines
Only bypass promptQuietMs when a held password-prefix chunk completes.
Fresh Password:/Enter password: lines in the flood wait for the quiet
gap like shell prompts, so canceled prompts do not resume drain early.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:16:21 +08:00
bincxzandCursor f1d2585279 fix: avoid control-regex lint in password-prefix detection
Replace the ASCII-range regex with a charCode scan so eslint
no-control-regex stays clean in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:14:26 +08:00
bincxzandCursor a558c16485 fix: hold mid-keyword password prompts with leading text
Detect trailing password-keyword prefixes at word boundaries so split
prompts like "alice@host's pass"+"word:" or "用户 的密"+"码" survive
Ctrl+C drain instead of being dropped mid-keyword.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:10:35 +08:00
bincxzandCursor b52d474819 fix: accept Kylin no-colon password prompts during drain
Align complete-password-prompt detection with terminalSudoAutofill so
prompts like "用户 的密码" resume Ctrl+C drain instead of being dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 04:02:36 +08:00
bincxzandCursor 18f00ec1af fix: tighten password-prefix hold and return discarded bytes
Limit held prefixes to true prompt-prefix forms so ordinary lines like
"Password authentication failed" are drained, and include discarded held
prefix bytes in returned droppedBytes so renderer flow ack stays accurate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:55:39 +08:00
bincxzandCursor b6f548283b fix: discard incomplete held password prefixes on drain resume
A held "Pass" that never completes as a password prompt must be dropped
before the generic shell-prompt matcher runs, otherwise "Pass$ " leaks
into the terminal after Ctrl+C drain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:48:13 +08:00
bincxzandCursor b9e8a45509 fix: hold split password prompts during Ctrl+C drain
Buffer probable password-prompt prefixes (e.g. "Pass") across chunks so a
split "Password:" / "[sudo] password…" is not dropped before it completes,
and resume immediately once the held prefix forms a complete prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:41:17 +08:00
陈大猫andCursor 3b3145358d fix(ai): resolve nvmd Windows Codex shims to native codex.exe (#2032)
Settings can probe ~/.nvmd/bin/codex.cmd, but the SDK path needs the real
native binary under versions/<ver>/. Without that override, packaged builds
fall back to optional platform deps we deliberately exclude.

Fixes #2027

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:33:10 +08:00
陈大猫andCursor 45c67d0a3c fix(terminal): clear WebGL atlas on pixel-only side-panel resize (#2033)
Opening SFTP (or other side panels) can shrink the terminal in pixels
without changing cols/rows, so term.onResize never clears the atlas and
WebGL keeps painting stale glyphs as black squares (#2013).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:32:50 +08:00
bincxzandCursor ea74a4cbf8 fix: keep sudo password prompts during Ctrl+C drain (#2010)
Interrupt output gates only resumed on shell-style prompts, so a password
prompt arriving in the quiet window was dropped as stale flood. Treat
sudo/password prompts as resume points on both main and renderer gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:32:06 +08:00
陈大猫andCursor f4fb0a5717 fix: stop window opacity slider oscillation from stale peer sync (#2031)
Version opacity updates and skip rebroadcasting incoming IPC/storage
echoes so a fast drag in Settings no longer fights delayed main-window
values (#2018).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:19:19 +08:00
陈大猫andCursor 606478435e fix(sftp): prevent corrupt uploads from oversized fastPut chunks (#2030)
* fix(sftp): prevent corrupt uploads from oversized fastPut chunks

Restore ssh2's 32KB default chunk size, verify remote size after upload,
and treat stream finish (not close) as success so truncated zip/tar.gz
transfers fail loudly instead of looking complete (#2022).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): wait for stream close after finish on upload fallback

Address Codex review: resolving on finish alone can miss close-time
server errors while the remote handle is still open. Require finish then
close, and fail on premature close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 03:19:05 +08:00
陈大猫andCursor 181c1d637e fix: default passphrase remember checkbox off (#2029)
SSH key passphrase prompts previously checked "Remember this passphrase" by default; make it opt-in for safer defaults (#2024).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 02:56:23 +08:00
陈大猫andCursor d00a966984 fix: Windows Mosh ConPTY handshake + Cygwin TERMINFO (#2025) (#2028)
* fix: make Windows Mosh handshake survive ConPTY and Cygwin terminfo

Windows ConPTY can inject CSI into MOSH CONNECT lines and exit without a
trailing newline, so the sniffer never swapped to mosh-client. Also convert
bundled TERMINFO paths to /cygdrive/... because Cygwin ncurses splits on ':'.

Fixes #2025

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: defer unterminated MOSH CONNECT parse until EOF

Avoid accepting a 22-char key prefix mid-stream before optional "=="
padding arrives; recover unterminated CONNECT only on SSH PTY flush.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 02:49:55 +08:00
陈大猫 cd4eb06cb1 Catch suspicious GitHub spam attachments 2026-07-08 13:07:34 +08:00
陈大猫 d60645365a fix: gate telnet naws resize updates (#2017) 2026-07-08 12:56:24 +08:00
陈大猫 bfe8baf39a Handle trailing punctuation in spam links 2026-07-08 11:50:32 +08:00
陈大猫 749021ea85 Add spam comment cleanup workflow 2026-07-08 11:44:42 +08:00
陈大猫 aba07c3e6f Update Nix release metadata for v1.1.60 (#2009) 2026-07-08 00:17:47 +08:00
陈大猫 4e2bd5f783 Fix main window focus stealing (#2008)
* Fix main window focus steal

* Address focus recovery review findings
2026-07-07 23:29:14 +08:00
陈大猫 b18f2bc447 feat: support Shift+Enter terminal newlines (#2005)
* feat: support Shift+Enter terminal newlines

* fix: harden Shift+Enter terminal handling

* fix: echo serial Shift+Enter newlines
2026-07-07 22:32:30 +08:00
陈大猫 69c735a908 Fix vault host drag state after grouping (#2007) 2026-07-07 22:30:53 +08:00
陈大猫 bb74a84eff fix: clarify MCP terminal tools in environment context
Clarify Netcatty MCP environment context so external agents that read resources can see the terminal execution tools, and cover the MCP resource path with a test.
2026-07-07 19:50:19 +08:00
陈大猫 49604ba432 Fix terminal search state hibernate guard (#2002) 2026-07-07 18:54:07 +08:00
陈大猫 72c133cc57 Follow up terminal tail performance fixes (#1996)
Refs #1992
Refs #1994
2026-07-07 18:11:15 +08:00
陈大猫 cef4c2ee30 Improve vault edit button placement (#2000) 2026-07-07 18:00:48 +08:00
陈大猫 816e494b27 Add terminal word separator setting (#1998) 2026-07-07 17:31:23 +08:00
陈大猫 33df2cad52 [codex] Keep background terminal output current (#1995)
* Keep background terminal output current

* Rename background terminal output helpers

* Flush deferred split panes on tab return

* Flush split pane scroll on tab return

* Recover visible terminal panes immediately

* Strengthen terminal freshness regression tests
2026-07-07 16:42:28 +08:00
陈大猫 e27a626b8d [codex] Reuse main window for deep links (#1990) 2026-07-07 12:56:29 +08:00
陈大猫 75977a7e15 Fix terminal tail output performance (#1994) 2026-07-07 12:50:41 +08:00
陈大猫 1d91c8fbf4 [codex] Add telnet URL deep link support (#1988)
* Add telnet URL deep link support

* Fix telnet deep link review issues

* Keep ssh deep links independent from telnet registration

* Address Codex telnet deep link feedback
2026-07-07 11:15:57 +08:00
陈大猫 1eb877e2aa [codex] Add S3 insecure sync option (#1986)
* Add S3 insecure sync option

* Harden S3 insecure option handling

* Clean S3 dependency lockfile

* Guard S3 fallback require usage
2026-07-07 10:40:26 +08:00
陈大猫 205f266441 [codex] Fix delete dialog overflow (#1987)
* Fix delete dialog overflow

* Cover group delete dialog overflow
2026-07-07 10:25:35 +08:00
陈大猫 6469336ba9 Update Nix release metadata for v1.1.59 (#1979) 2026-07-07 01:08:35 +08:00
陈大猫 c9b9a4f5d1 Prevent JMS tokens from sudo autofill (#1977) 2026-07-07 00:26:47 +08:00
陈大猫andCursor f08ca1c08a Support JumpServer jms:// client launch links (#1965) (#1976)
* Support JumpServer jms:// client launch links

Register Netcatty as an optional jms:// protocol handler (opt-in,
default off to avoid conflicting with the official JumpServer client).
SSH/SFTP connection payloads decode to an ephemeral host that connects
to the JumpServer gateway with the one-time connection token, without
saving anything to the vault.

Fixes #1958



* Mark jms deep link hosts as ephemeral for session restore exclusion



* Open the SFTP side panel for JumpServer sftp launch links

jms:// payloads with protocol "sftp" previously landed in a plain
shell. Mark the ephemeral host with an autoOpenSftpPanel hint that the
session factory forwards as autoOpenSidePanel, and let TerminalLayer
open the SFTP side panel for that session once it connects.



* Support JumpServer telnet deep links

* Keep JumpServer telnet links on SSH gateway

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 00:07:20 +08:00
陈大猫andCursor c6baebd514 Support password in ssh:// deep links with ephemeral connect (#1963)
* Support password in ssh deep links with ephemeral connect

ssh://user:password@host URLs now connect directly using the URL
credentials without saving the host to the vault, covering bastion
one-time password flows. Password-less links keep the existing
match-or-prefill behavior.

Fixes #1890

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep ephemeral deep-link hosts out of vault update paths

Terminal-layer host updates (e.g. the SFTP follow-CWD toggle) map over
the merged terminal host list, which could persist an ephemeral host
and its plaintext password into the vault. Route terminal-layer host
updates through a splitter that applies ephemeral entries to in-memory
state only and forwards just the vault hosts to updateHosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Exclude ephemeral deep-link sessions from session restore

Sessions backed by an in-memory ephemeral host cannot survive a
relaunch (the one-time password only lives in memory), so persisting
them would leak the ephemeral host metadata into restore storage and
restore a tab that can never reconnect. Mark ephemeral hosts and their
sessions, and filter them out of the session-restore payload.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Treat ephemeral hosts as unsaved in appearance classification

The terminal theme/font panel and pane appearance resolution decided
"saved vault host" via hostMap membership. Ephemeral deep-link hosts
are now in that map, so theme/font changes were routed to the
persisted-host updater and silently dropped. Classify via
isSavedVaultHost (ephemeral flag aware) so those changes fall back to
the session/global setting path as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse saved host settings for password deep links and keep ephemeral marker on clones

Password deep links now run saved-host matching first: a uniquely
matched vault host contributes its non-credential settings (proxy,
jump chain, charset, group, ...) to the ephemeral host while the URL
credentials override vault identities and key references. Session
clones (split/copy/copy-to-new-window) now carry the ephemeralHost
marker so cloned tabs stay excluded from session restore.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Block group credential inheritance on password deep-link ephemeral hosts

buildSshDeepLinkEphemeralHostFromSaved now takes the group-resolved
effective host and clears group/identityFilePaths in addition to
identity references, so a later resolveEffectiveHost pass cannot
re-inherit group default credentials (identity, key, password) over
the one-time URL password. Non-credential group defaults (proxy,
jump chain, charset, ...) are preserved via the effective host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route ephemeral-host font zoom through the per-session font-size path

Ctrl+zoom on a non-workspace ephemeral deep-link session hit the
saved-host update path, where the vault updater silently dropped the
change (ephemeral ids are not in the persisted hosts array). Zoom and
the theme panel font-size slider/reset now store the size on the
session (rendered via applySessionFontSizeToHost) for ephemeral
hosts, matching the workspace-pane behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix ephemeral session font zoom routing

* Prevent ephemeral deep-link sudo autofill

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:56:32 +08:00
陈大猫 34c3c5beca [codex] Add Finder folder open terminal support (#1973)
* Add Finder folder open terminal support

* Fix Finder terminal path review findings
2026-07-06 22:37:19 +08:00
陈大猫 34eb3cd8fc Address Vault delete confirmation review feedback (#1975) 2026-07-06 22:20:39 +08:00
陈大猫 3d939480c9 Add Vault delete confirmations (#1974) 2026-07-06 22:07:28 +08:00
陈大猫andCursor 6400ce1e75 fix(terminal): detach stale keychain identity when saving retry credentials (#1970)
* fix(terminal): detach stale keychain identity when saving retry credentials

When a host references a Keychain identity (host.identityId) with wrong
credentials, re-entering credentials via the auth-retry dialog's
"Continue & Save" saved them onto the host but kept the identity
reference. On reconnect, resolveHostAuth prefers identity credentials
over host fields, so the stale keychain credentials kept failing.

Clear identityId when the user explicitly saves new credentials so the
saved username/password (or key) actually takes effect.

Fixes #1956

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep saved retry credentials over group-inherited identity

Writing identityId: undefined is treated as "unset" by applyGroupDefaults,
so a group-level identity would be re-inherited on reconnect and shadow the
credentials saved from the auth-retry dialog. Store an empty string instead
and register identityId in EMPTY_STRING_OVERRIDES_GROUP_DEFAULT so the
explicit "no identity" override sticks. All consumers check identityId
truthiness, so "" behaves as "no identity".

Addresses Codex review on #1970.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:50:33 +08:00
陈大猫 6cda26fad7 Fix confirm dialog input lock (#1969) 2026-07-06 19:46:39 +08:00
陈大猫andCursor 3deeb74ca7 Re-inject tool call names on OpenAI-compatible stream desync (#1966)
* Re-inject tool call names on OpenAI-compatible stream desync

Some OpenAI-compatible gateways emit tool_call chunks the AI SDK
rejects or ignores (non-standard `type` values, tool calls outside
choices[0]). The chunk normalizer had already recorded the tool call
id, so later nameless continuation chunks were forwarded with an
injected id and the SDK threw "Expected 'function.name' to be a
string." on what it saw as a brand-new tool call.

Remember each tool call's function name when its id is assigned and
re-inject it (plus type "function") into continuation chunks so every
forwarded chunk is self-describing, and coerce non-standard tool_call
type strings to "function" so the naming chunk itself is not dropped
by schema validation.

Fixes #1954.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Preserve argument prefixes from SDK-invisible tool call chunks

Codex review on #1966 flagged that when the chunk establishing a tool
call is invisible to the AI SDK (which only reads choices[0]), any
argument fragment it carried was lost: the recovery path re-injected
the name but forwarded only the suffix arguments, producing truncated
tool input.

Track the full concatenation of argument fragments per tool call key
and which keys have been forwarded at choices array position 0. The
first chunk forwarded where the SDK can see it now carries the full
accumulated arguments; already-visible tool calls keep streaming
incremental fragments as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:22:08 +08:00
陈大猫andCursor 476e441057 fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961) (#1968)
* fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961)

Bulk terminal output over SSH (e.g. `tail -2000f big.log`) was far slower
than other clients on high-latency links. The renderer's output flow control
pauses the source stream once its unacked backlog crosses FLOW_HIGH_WATER_MARK
(previously 100KB). For SSH the source is the ssh2 channel, so pause() stops
the remote until resume() plus a full round-trip.

ssh2's own channel window is 2MB (WINDOW_THRESHOLD 1MB), so a 100KB watermark
made Netcatty pause/resume dozens of times during a multi-MB dump — a 4MB dump
triggered ~61 pause cycles, each costing ~1 RTT. At 300ms RTT that turned a
~2s dump into ~21s.

Raise FLOW_HIGH_WATER_MARK to 1MB so it sits near the ssh2 window; the same
4MB dump now settles in ~4 pause cycles (~2-3s at 300ms RTT) while still
bounding renderer memory. FLOW_LOW_WATER_MARK stays at 8KB to keep interactive
acks responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(terminal): align deferred-ack test with 1MB flow high-water mark

The deferred IPC ack batch threshold (XTERM_WRITE_CALLBACK_BATCH_BYTES)
now sits far below FLOW_HIGH_WATER_MARK, so deferral alone can no longer
accumulate up to the watermark. Assert the batch-flush invariant instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:18:27 +08:00
陈大猫 965c1c581a Fix script waits misjudging fresh multi-line output as stale (#1964) 2026-07-06 18:14:29 +08:00
陈大猫andCursor 99e1a8d6d8 fix(zmodem): keep transfer listeners across same-session reconnects (#1962)
* fix(zmodem): keep transfer listeners across same-session reconnects

The preload netcatty:exit handler dropped the per-session zmodem
listener sets, but auth-failure retries and auto-reconnects reuse the
same sessionId without remounting the terminal, so useZmodemTransfer
never re-subscribed and rz/sz progress + overwrite dialogs silently
stopped appearing (#1955). Move listener cleanup to explicit
closeSession and prune empty sets on subscriber dispose instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(zmodem): gate event delivery instead of dropping listeners on close

Codex review caught that reconnects also call closeSession with the
terminal still mounted (startReconnect -> cleanupSession), so clearing
listener maps there reintroduced the bug for manual/auto reconnects.
Keep listener sets owned by renderer subscribers and gate zmodem event
delivery on closedTerminalDataSessions, which is cleared when a session
restarts with the same id.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:48:05 +08:00
陈大猫 0558dd6f6d Update Nix release metadata for v1.1.58 (#1948) 2026-07-06 00:29:18 +08:00
陈大猫andCursor 86e2336370 Fix directory tracking setup after su / sudo su (#1946)
* Fix directory tracking setup after su / sudo su (#1942)

After switching users in the terminal (e.g. `sudo su -`), SFTP directory
following stops: the new user's shell has no OSC 7 prompt hook, and the
backend pwd probe runs on an exec channel owned by the login user, which
cannot read another user's /proc/<pid>/cwd. The advertised remedy — the
OSC 7 setup action — also failed in this exact scenario because its silent
exec-channel script cannot verify or configure a foreground shell owned by
another user.

The setup script now detects that the active foreground shell belongs to a
different uid, reports the target shell via a marker (exit 5), and the
renderer falls back to typing the setup command into the terminal itself,
where it runs as the target user. The config lands in that user's rc file,
so OSC 7 reporting resumes immediately and persists for future su sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address Codex review: stage typed setup remotely, forward shell-local config paths

The typed fallback previously pasted the whole multi-line setup script into
the terminal, which could leave many entries in the target user's bash
history when cmdhist is off (the appended history -d only removes the last
one). The setup script is now staged into a self-deleting remote temp file
over the silent exec channel, and the typed command becomes a short single
line — one history entry that the existing bash cleanup reliably removes.

The typed wrapper also forwards shell-local (possibly unexported) ZDOTDIR /
XDG_CONFIG_HOME to the child sh via the NETCATTY_* overrides the script
already honors, so zsh users with a custom config dir get the prompt hook in
the right rc file (mirrors buildOsc7SetupCommand).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address Codex review: hash-verify the staged setup script before the su shell runs it

The staged temp file stays writable by the login user until the typed
command runs, so a same-uid process could swap its contents in that window
and have the su-target (e.g. root) shell execute arbitrary code. The typed
runner now reads the file into memory once, deletes it, verifies the
renderer-computed SHA-256 of the in-memory copy, and only then pipes that
same copy to sh — no TOCTOU window between verification and execution.
Verification failure prints an error instead of running anything.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 23:46:28 +08:00
LaiYueJi 6f7d18ffba fix(i18n): localize SSH auth-failure retry messages (#1947)
The "Authentication failed…" retry prompt and progress-log line in
createTerminalSessionStarters were hardcoded English and bypassed i18n,
so they rendered in English regardless of the selected language.

Route both through the existing tr() helper and add
terminal.auth.retryMessage / terminal.auth.retryLog to en, ru, zh-CN
and zh-TW.
2026-07-05 23:46:14 +08:00
陈大猫andCursor 6cb68bf6fc Run agent commands in a subshell so failures cannot close the session (#1945)
* Run agent commands in a subshell so failures cannot close the session

Agent-generated commands were eval'd directly in the user's active login
shell, so a generated script containing `set -e` (or `exit`) terminated
the whole SSH session on the first failure. Wrap the eval in a subshell
unconditionally instead of trying to detect dangerous commands — the
detection approaches from PRs #1852/#1882 grew into shell parsing and
were abandoned. Output stays live in the user's terminal; the trade-off
is that cd/export no longer persist between calls, which the tool
descriptions now tell the model about.

Also fix multi-line command display: synthetic echo now normalizes line
breaks to CRLF (no more staircase indentation in xterm), and the POSIX
wrapper rebuilds newlines via printf so the typed command stays on one
physical line and PS2 continuation echoes ("> ...") no longer leak past
the marker filter.

Fixes #1850

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move shellUtils require to module scope in cattyExecHandlers

The inline require("./ai/shellUtils.cjs") inside the with(ctx) block did
resolve correctly at runtime (bare `require` hits ctx.require, based in
electron/bridges/), but the path only works through that indirection and
reads as wrong relative to this file. Require at module scope with the
unambiguous ../ai path instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 23:08:16 +08:00
陈大猫andCursor bccc085029 fix(ai): allow OpenCode native skill directories in permission rules (#1943)
* fix(ai): allow OpenCode native skill dirs in permission rules

Netcatty's OpenCode permission config denied external_directory access
for everything outside its own allowlist, so skills discovered by
OpenCode itself (~/.opencode/skills, ~/.config/opencode/skills,
~/.claude/skills, ~/.agents/skills, remote skill cache) could not read
their reference files. OpenCode evaluates rules last-match-wins and its
read permission matches worktree-relative paths, so wildcard-anchored
patterns for the well-known skill directories are added to both the
read and external_directory rules in MCP and skills+cli modes.

Fixes #1939

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): re-deny dot-env files inside OpenCode skill dir allows

The broad skill-directory read allows would win over OpenCode's default
.env / .env.* approval gates (last matching rule wins), silently exposing
secret files placed inside native skill directories. Append targeted deny
rules after the allow entries so dot-env files stay protected.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:28:52 +08:00
陈大猫andCursor 2d95c71b85 Fix manual session log capture in terminal worker mode (#1944)
* Fix manual session log capture in terminal worker mode

Manual session logs (and script session logs) start their write stream in
the main process, but with the default terminal worker runtime all session
output is appended to the worker process's own sessionLogStreamManager
instance, so the saved file only ever contained the initial prompt line.

Tap the worker output mirror into the main-process stream manager so
main-process log streams receive terminal output, and mirror sudo-autofill
and programmatic command log rewrites when forwarding writes to the worker
so those logs stay sanitized like worker-owned auto session logs.

Fixes #1938

Co-authored-by: Cursor <cursoragent@cursor.com>

* Dedupe fallback worker output before session log taps

The runtime sender announces every chunk with an output-tap message and
falls back to a plain output message when the output port is unusable;
the manager notified taps for both, double-writing manual/script session
logs in the fallback path. Mark fallback output as already tapped so taps
fire exactly once per chunk.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:27:16 +08:00
陈大猫andCursor effafd9c2e Batch terminal output hot-path scans to raise renderer throughput. (#1941)
Every output chunk was walked char-by-char up to four times in JS
(sync-block filter, erase-scrollback rewrite, replay-safe log sanitizer,
line-timestamp segmenter) plus two more per-char scans for long-line
pressure, before xterm.js ever parsed it. Replace those loops with
native indexOf/regex hops and single-slice appends, and gate the
quadratic pending-suffix scan in the sync filter behind a cheap
incomplete-escape check. Output is byte-for-byte identical (fuzzed
old-vs-new over 4000 mixed chunks); colored-output floods drop from
~350ms to ~0.03ms per 64KB batch in the sync filter alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:52:44 +08:00
陈大猫andCursor aebdcedda9 Fix terminal catch-up lag when window is backgrounded unfocused (#1880) (#1940)
* Fix terminal backlog when window is unfocused in background.

Extend the background write fast path to occluded-but-visible windows (common on Windows Alt+Tab), skip wasteful repaints during that path, and flush any remaining coalescer/queue backlog when the app returns to foreground.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix resume hook wiring and keep unfocused repaint in fast path.

The onWindowShown resume flush never fired because useTerminalBackend did not expose the bridge listener. Also restore scheduleTerminalRepaintWhenUnfocused on the background fast path: unfocused-but-visible windows have no rAF render loop, so skipping it froze visible output (#1761 regression).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:51:41 +08:00
陈大猫 74b05c83d0 Update Nix release metadata for v1.1.57 2026-07-04 23:00:51 +08:00
陈大猫 94e359f6d7 [codex] Add macOS Dock new connection menu (#1935)
* Add macOS Dock new connection menu

* Fix Dock menu window readiness

* Wait for renderer before Dock menu delivery

* Queue Dock host connects until vault ready
2026-07-04 22:14:44 +08:00
陈大猫 101ff24a1f [codex] add system overview dashboard (#1934)
* Add system overview dashboard

* Fix system overview polling edge cases
2026-07-04 21:52:14 +08:00
陈大猫 9fe1fc7aa4 Fix manual session log capture (#1933) 2026-07-04 20:29:43 +08:00
陈大猫 1dbf46e32f Default SSH auto reconnect off (#1932) 2026-07-04 19:37:11 +08:00
陈大猫 6e3401f9dc [codex] Add SSH auto reconnect (#1931)
* Add SSH auto reconnect

* Fix auto reconnect for hibernated sessions
2026-07-04 19:20:24 +08:00
陈大猫 6944b77ec7 Fix terminal Shift mouse selection (#1930) 2026-07-04 19:04:45 +08:00
陈大猫 3f2e33f6e0 [codex] Remember serial YMODEM send directory (#1928)
* Remember serial YMODEM send directory

* Handle YMODEM path edge cases
2026-07-04 17:38:33 +08:00
陈大猫 2b9846d1d2 Fix terminal font remeasure after load (#1927) 2026-07-04 17:04:48 +08:00
陈大猫 b6a7f40175 Fix Ctrl-L scrollback preservation (#1923) 2026-07-04 16:02:31 +08:00
陈大猫 aa4b7f46e5 Show enter reconnect hint (#1925) 2026-07-04 15:14:55 +08:00
HLDandbincxz c79d51858a feat: add script dialog form controls (#1907)
* feat: add script dialog form controls

* feat: add script dialog text and number fields

* feat: add conditional script dialog fields

* fix: address script dialog review feedback

* fix: harden script dialog form validation

* fix: harden script dialog review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-04 15:04:08 +08:00
陈大猫 e430a42dbe Fix SFTP follow cwd manual navigation (#1922) 2026-07-04 15:03:01 +08:00
陈大猫 5dec129088 [codex] Add enter reconnect for disconnected terminals (#1924)
* Add enter reconnect for disconnected terminals

* Honor workspace compose bar for enter reconnect
2026-07-04 15:00:45 +08:00
陈大猫 f299271b1b fix opencode windows permission allowlist (#1921) 2026-07-04 14:02:12 +08:00
陈大猫 49787215d2 Fix RPM build-id file conflicts (#1919) 2026-07-04 13:34:04 +08:00
陈大猫 5e34538c55 Add double-click copy for session tabs (#1917) 2026-07-04 10:59:34 +08:00
陈大猫 862c3f6fb5 Fix vault host drag reset (#1916) 2026-07-04 10:22:03 +08:00
陈大猫 2edbeae02c Remove unused code and assets
Remove unused code, assets, and stale config entries.
2026-07-03 10:47:59 +08:00
陈大猫 02ada0c61d Update Nix release metadata for v1.1.56
Update Nix release metadata for v1.1.56
2026-07-03 00:30:15 +08:00
490 changed files with 36866 additions and 5224 deletions

No files matched your search

-234
View File
@@ -1,234 +0,0 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 pinned runtime bundle.
# Do not compile this in CI: rebuilding the upstream Cygwin client here
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
# verified by fetch-windows.sh.
# ------------------------------------------------------------------
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch pinned mosh-client bundle (win32-x64)
run: |
set -euo pipefail
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi
+188 -17
View File
@@ -354,11 +354,17 @@ jobs:
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
# compatible with most current Linux distributions including Arch.
# See #264.
# Dedicated job for Linux x64 — builds inside AlmaLinux 8 (GLIBC 2.28)
# so native modules (node-pty, serialport) stay compatible with RHEL 8 /
# CentOS 8 / UOS / Deepin. Ubuntu 22.04 links against GLIBC 2.32–2.35,
# which crashes on those older hosts (see #2062).
#
# We previously used debian:buster for the same glibc floor, but Buster's
# g++ 8 cannot compile Electron 42 natives (`-std=gnu++20`). AlmaLinux 8
# keeps glibc 2.28 while gcc-toolset-13 provides a real C++20 toolchain.
# Link natives with -static-libstdc++ so the result does not require a
# newer system libstdc++ on RHEL 8 derivatives.
# Key: GLIBC < 2.34 also avoids the libpthread-merge symbol requirement.
build-linux-x64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh, resolve-et]
@@ -366,12 +372,16 @@ jobs:
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
container:
image: almalinux:8
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
# Keep rebuilt natives portable to stock RHEL 8 libstdc++.
LDFLAGS: "-static-libstdc++ -static-libgcc"
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -399,20 +409,159 @@ jobs:
exit 1
fi
- name: Install build dependencies
shell: bash
run: |
set -euo pipefail
dnf -y install epel-release
dnf -y install \
curl ca-certificates \
gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ make \
python3.11 python3.11-pip \
git file rpm-build binutils cpio elfutils \
libarchive gzip bzip2 xz zstd tar which findutils \
dpkg fakeroot \
fuse fuse-libs gtk3 nss libXScrnSaver libXtst alsa-lib \
at-spi2-atk libdrm mesa-libgbm libX11 libX11-xcb libxcb \
mesa-libGL pango cairo
# gcc-toolset-13: C++20 for Electron 42 natives; still links glibc 2.28.
GCC_TOOLSET_ROOT="/opt/rh/gcc-toolset-13/root"
echo "PATH=${GCC_TOOLSET_ROOT}/usr/bin:${PATH}" >> "${GITHUB_ENV}"
echo "CC=${GCC_TOOLSET_ROOT}/usr/bin/gcc" >> "${GITHUB_ENV}"
echo "CXX=${GCC_TOOLSET_ROOT}/usr/bin/g++" >> "${GITHUB_ENV}"
echo "DEVTOOLSET_ROOTPATH=${GCC_TOOLSET_ROOT}" >> "${GITHUB_ENV}"
export PATH="${GCC_TOOLSET_ROOT}/usr/bin:${PATH}"
gcc --version
g++ --version
# AlmaLinux 8's libarchive package is often library-only in minimal
# images (no /usr/bin/bsdtar). electron-builder pacman packaging needs
# bsdtar; pull a Debian tools package that runs on glibc 2.28.
# Prefer Buster (published as +deb10u1 on archive.debian.org).
if [[ ! -x /usr/bin/bsdtar ]]; then
echo "bsdtar missing; installing CLI from Debian libarchive-tools"
mkdir -p /tmp/bsdtar-deb
BSDTAR_DEB_URLS=(
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.3.3-4+deb10u1_amd64.deb"
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.2.2-2+deb9u2_amd64.deb"
)
BSDTAR_DEB_OK=0
for url in "${BSDTAR_DEB_URLS[@]}"; do
echo "trying ${url}"
if curl -fsSL -o /tmp/libarchive-tools.deb "${url}"; then
BSDTAR_DEB_OK=1
break
fi
echo "download failed for ${url}"
done
if [[ "${BSDTAR_DEB_OK}" -ne 1 ]]; then
echo "::error::Could not download Debian libarchive-tools for bsdtar"
exit 1
fi
dpkg-deb -x /tmp/libarchive-tools.deb /tmp/bsdtar-deb
install -m 0755 /tmp/bsdtar-deb/usr/bin/bsdtar /usr/local/bin/bsdtar-bin
# Prefer system libarchive.so if present; fall back to the deb's.
if [[ ! -e /usr/lib64/libarchive.so.13 && -d /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu ]]; then
mkdir -p /usr/local/lib/bsdtar-libs
cp -a /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu/libarchive.so* /usr/local/lib/bsdtar-libs/ 2>/dev/null || true
fi
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
'fi' \
'exec /usr/local/bin/bsdtar-bin "$@"' \
> /usr/local/bin/bsdtar
chmod +x /usr/local/bin/bsdtar
fi
# Some fpm paths still look for xzmt even when payload compression
# is gzip; provide a multi-thread xz shim.
printf '%s\n' '#!/bin/sh' 'exec /usr/bin/xz -T0 "$@"' > /usr/local/bin/xzmt
chmod +x /usr/local/bin/xzmt
# electron-builder's portable fpm injects its own LD_LIBRARY_PATH.
# System tools (rpmbuild, bsdtar, ...) then exit 127 under that path.
# Wrap every packaging helper so child processes see a clean env.
# Use printf (not a bare heredoc): unindented heredoc body breaks GHA YAML.
wrap_clean_env() {
local name="$1"
local real=""
if [[ -x "/usr/bin/${name}" ]]; then
real="/usr/bin/${name}"
elif [[ -x "/usr/local/bin/${name}-bin" ]]; then
real="/usr/local/bin/${name}-bin"
elif command -v "${name}" >/dev/null 2>&1; then
real="$(command -v "${name}")"
else
return 0
fi
# Don't wrap our own wrapper recursively.
if [[ "${real}" == /usr/local/bin/* && "${name}" != "bsdtar" ]]; then
return 0
fi
if [[ "${name}" == "bsdtar" && -x /usr/local/bin/bsdtar-bin ]]; then
real="/usr/local/bin/bsdtar-bin"
fi
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
"exec ${real} \"\$@\"" \
> "/usr/local/bin/${name}"
chmod +x "/usr/local/bin/${name}"
}
for cmd in rpmbuild bsdtar bsdcpio tar gzip xz strip cpio; do
wrap_clean_env "${cmd}"
done
# Re-apply bsdtar wrapper with optional lib path for the deb-extracted binary.
if [[ -x /usr/local/bin/bsdtar-bin ]]; then
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
'fi' \
'exec /usr/local/bin/bsdtar-bin "$@"' \
> /usr/local/bin/bsdtar
chmod +x /usr/local/bin/bsdtar
fi
# Fail early with diagnostics if packaging tools are missing.
for tool in rpmbuild bsdtar strip gzip xz xzmt; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "::error::Required packaging tool not found: ${tool}"
ls -la /usr/bin/bsd* /usr/local/bin/bsd* 2>/dev/null || true
rpm -ql libarchive 2>/dev/null | head -n 40 || true
exit 1
fi
echo "found ${tool}: $(command -v "${tool}")"
done
rpmbuild --version
bsdtar --version | head -n 1
# Official Node 22 linux-x64 tarball (same major as other package jobs).
NODE_VERSION="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt \
| awk '/node-v[0-9.]+-linux-x64\.tar\.xz$/{print $2; exit}' \
| sed -E 's/^node-(v[0-9.]+)-linux-x64\.tar\.xz$/\1/')"
if [[ -z "${NODE_VERSION}" ]]; then
echo "::error::Could not resolve latest Node 22 linux-x64 tarball version."
exit 1
fi
echo "Installing Node ${NODE_VERSION} from nodejs.org"
curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1
node -v
npm -v
# node-gyp 12 needs Python >=3.8; AlmaLinux 8 default is older.
alternatives --set python3 /usr/bin/python3.11 || ln -sfn /usr/bin/python3.11 /usr/local/bin/python3
python3 --version
echo "PYTHON=/usr/bin/python3.11" >> "${GITHUB_ENV}"
echo "npm_config_python=/usr/bin/python3.11" >> "${GITHUB_ENV}"
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
run: |
python3 --version
g++ --version
npm ci
- name: Set version
shell: bash
@@ -445,7 +594,23 @@ jobs:
env:
npm_config_arch: x64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
# Natives are already rebuilt. Prefer system packaging tools so
# rpmbuild brp scripts do not pick up a toolset-only binary, and so
# /usr/local/bin/rpmbuild (clean-env wrapper) wins over /usr/bin.
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
run: |
set -euo pipefail
command -v node
command -v rpmbuild
command -v bsdtar
command -v gzip
command -v xzmt
for cmd in rpmbuild bsdtar; do
if ! head -n 2 "$(command -v "${cmd}")" | grep -q 'unset LD_LIBRARY_PATH'; then
echo "::warning::${cmd} on PATH is not the clean-env wrapper; packaging may fail with exit 127"
fi
done
npm run pack:linux-x64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -453,6 +618,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -566,6 +734,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+50
View File
@@ -0,0 +1,50 @@
name: spam-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
delete-spam:
runs-on: ubuntu-latest
steps:
- name: Checkout filter rules
uses: actions/checkout@v4
- name: Delete suspected malware spam
uses: actions/github-script@v7
with:
script: |
const path = require('node:path');
const { detectSpamComment } = require(
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
);
const comment = context.payload.comment;
const result = detectSpamComment({
body: comment.body || '',
authorAssociation: comment.author_association,
userType: comment.user && comment.user.type,
});
core.info(JSON.stringify(result, null, 2));
if (!result.spam) {
core.info('Comment did not match the zip/dangerous-file spam filter.');
return;
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
core.notice(
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
);
+6 -8
View File
@@ -42,6 +42,9 @@ coverage
# Codex
/.codex/
# OMO / local agent run-continuation state
/.omo/
# Qoder
.qoder
@@ -74,16 +77,11 @@ Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
# and the bundled ncurses terminfo database are all pulled from the
# dedicated mosh binary repository, never committed.
# Bundled MoshCatty mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is committed;
# pure single-binary artifacts from binaricat/MoshCatty are never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/
# Bundled EternalTerminal `et` client binaries fetched at pack time by
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
+235 -12
View File
@@ -25,7 +25,10 @@ import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from './domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
@@ -50,6 +53,8 @@ import {
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { syncExternalMcpStartupState } from './application/state/useExternalMcpToggleState';
import { useExternalMcpSessionSync } from './application/state/useExternalMcpSessionSync';
import {
STORAGE_KEY_DEBUG_HOTKEYS,
STORAGE_KEY_PORT_FORWARDING,
@@ -57,6 +62,7 @@ import {
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ConfirmDialog } from './components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from './components/port-forwarding';
import { VaultSection } from './components/VaultView';
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
@@ -68,11 +74,14 @@ import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalS
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { isScriptSnippet } from './domain/snippetScript.ts';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -109,11 +118,14 @@ function App({ settings }: { settings: SettingsState }) {
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
const [ephemeralHosts, setEphemeralHosts] = useState<Host[]>([]);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
@@ -171,6 +183,16 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [workspaceFocusStyle]);
// External MCP: only persistent+enabled restores at app startup.
// Keep this on the App mount path (not Settings) so temporary mode is not
// accidentally disabled when the AI settings page remounts.
// Skip peer session windows — they also mount App and must not clear a
// temporary-mode runtime that the main window already started.
useEffect(() => {
if (isPeerSessionWindow) return;
syncExternalMcpStartupState(netcattyBridge.get());
}, [isPeerSessionWindow]);
const {
isInitialized: isVaultInitialized,
hosts,
@@ -337,6 +359,14 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const terminalHosts = useMemo(
() => (ephemeralHosts.length > 0 ? [...hosts, ...ephemeralHosts] : hosts),
[hosts, ephemeralHosts],
);
const ephemeralHostIds = useMemo(
() => new Set(ephemeralHosts.map((host) => host.id)),
[ephemeralHosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
@@ -374,6 +404,13 @@ function App({ settings }: { settings: SettingsState }) {
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
// App-level External MCP session sync (before TerminalLayer lazy-mount).
useExternalMcpSessionSync({
sessions,
hosts: terminalHosts,
portForwardingRules,
});
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
@@ -557,6 +594,7 @@ function App({ settings }: { settings: SettingsState }) {
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
@@ -589,7 +627,7 @@ function App({ settings }: { settings: SettingsState }) {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
_handleTrayPanelConnectRequest(hostId);
});
return () => {
unsubscribeJump?.();
@@ -597,6 +635,15 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: _handleTrayPanelConnect,
pendingHostIds: pendingTrayPanelConnectHostIds,
setPendingHostIds: setPendingTrayPanelConnectHostIds,
}));
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
@@ -893,10 +940,14 @@ function App({ settings }: { settings: SettingsState }) {
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
const confirmed = window.confirm(t('confirm.deleteHost', { name: target?.label || hostId }));
if (!confirmed) return;
updateHosts(hosts.filter(h => h.id !== hostId));
}, [hosts, updateHosts, t]);
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
const handleAddKnownHost = useCallback((kh: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
@@ -929,7 +980,16 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => { return handleCreateLocalTerminalImpl(() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }), shell); }, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const handleCreateLocalTerminal = useCallback((
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) => {
return handleCreateLocalTerminalImpl(
() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }),
shell,
options,
);
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
@@ -947,6 +1007,23 @@ function App({ settings }: { settings: SettingsState }) {
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const openHostForVaultAgent = useCallback((hostId: string) => {
const host = hosts.find((item) => item.id === hostId);
if (!host) {
return { ok: false as const, error: `Host "${hostId}" was not found.` };
}
const sessionId = handleConnectToHost(host);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${hostId}".` };
}
// Surface the main window for external MCP / CLI open requests.
void netcattyBridge.get()?.openMainWindow?.();
return { ok: true as const, sessionId, host };
}, [handleConnectToHost, hosts]);
useVaultAgentBridge({
hosts,
snippets,
@@ -963,11 +1040,9 @@ function App({ settings }: { settings: SettingsState }) {
updateNotes,
startTunnel,
stopTunnel,
openHost: openHostForVaultAgent,
});
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
@@ -985,6 +1060,23 @@ function App({ settings }: { settings: SettingsState }) {
};
});
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
if (target.password) {
// One-time-password link: connect ephemerally with exactly the URL
// credentials. A uniquely matched saved host still contributes its
// non-credential settings (proxy, jump chain, charset, ...). Build
// from the group-resolved effective host so the builder can clear
// `group` and block group credential inheritance from later
// effective-host resolution.
const draftOptions = { id: crypto.randomUUID(), now: Date.now() };
const ephemeralHost = matchedEffectiveHost
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
: buildSshDeepLinkEphemeralHost(target, draftOptions);
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
return;
}
if (matchedEffectiveHost) {
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
@@ -1008,6 +1100,106 @@ function App({ settings }: { settings: SettingsState }) {
});
}, [isPeerSessionWindow]);
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseTelnetDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.telnet.invalid'));
return;
}
const effectiveHosts = hosts.map((host) =>
materializeTelnetDeepLinkMatchHost(resolveEffectiveHost(host), identities),
);
const matchedEffectiveHost = findTelnetDeepLinkHost(effectiveHosts, target, {
ignoreTargetUsername: Boolean(target.password),
});
if (matchedEffectiveHost) {
if (target.password) {
const ephemeralHost = buildTelnetDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
return;
}
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
return;
}
const ephemeralHost = buildTelnetDeepLinkOpenHost(effectiveHosts, target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onTelnetDeepLink) return;
return bridge.onTelnetDeepLink((payload) => {
_handleTelnetDeepLink(payload);
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.jms.invalid'));
return;
}
if (!isSupportedJmsProtocol(target.protocol)) {
toast.warning(t('deepLink.jms.unsupported', { protocol: target.protocol }));
return;
}
const ephemeralHost = buildJmsDeepLinkEphemeralHost(target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onJmsDeepLink) return;
return bridge.onJmsDeepLink((payload) => {
_handleJmsDeepLink(payload);
});
}, [isPeerSessionWindow]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
const referencedHostIds = new Set(
sessions.map((session) => session.hostId).filter((id): id is string => Boolean(id)),
);
const next = prev.filter((host) => referencedHostIds.has(host.id));
return next.length === prev.length ? prev : next;
});
}, [sessions]);
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onOpenTerminalPath) return;
return bridge.onOpenTerminalPath((payload) => {
_handleOpenTerminalPath(payload);
});
}, [isPeerSessionWindow]);
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
const tabId = handleConnectToHost(host);
if (source?.noteId && typeof tabId === 'string' && tabId) {
@@ -1073,6 +1265,17 @@ function App({ settings }: { settings: SettingsState }) {
)));
}, [hosts, updateHosts]);
// Terminal-layer host updates may include ephemeral deep-link hosts; keep
// those in memory only and never let them reach the persisted vault.
const updateTerminalHosts = useCallback((nextHosts: Host[]) => {
const { vaultHosts, ephemeralHosts: updatedEphemeralHosts } =
splitHostsUpdateByEphemeral(nextHosts, ephemeralHostIds);
if (updatedEphemeralHosts.length > 0) {
setEphemeralHosts((prev) => applyEphemeralHostsUpdate(prev, updatedEphemeralHosts));
}
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1178,6 +1381,16 @@ function App({ settings }: { settings: SettingsState }) {
return (
<>
<PortForwardHostKeyDialog onAddKnownHost={handleAddKnownHost} />
<ConfirmDialog
open={deleteHostConfirm !== null}
title={deleteHostConfirm ? t('confirm.deleteHost', { name: deleteHostConfirm.name }) : ''}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) setDeleteHostConfirm(null);
}}
onConfirm={handleConfirmDeleteHost}
/>
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
@@ -1195,7 +1408,7 @@ function App({ settings }: { settings: SettingsState }) {
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
@@ -1223,11 +1436,21 @@ function AppWithProviders() {
}
}, []);
// Keep MCP/SDK approval IPC alive for the whole app lifetime — including the
// ~5s before TerminalLayer lazy-mounts, and when External MCP is used without
// opening the Catty AI panel.
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
useExternalMcpGrantPersister();
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
+28 -25
View File
@@ -8,37 +8,42 @@
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
> Mosh 模板参考(**仅 MoshCatty 纯二进制路径**):`resources/mosh/README.md`、
> `scripts/fetch-mosh-binaries.cjs`、`scripts/resolve-mosh-bin-release.cjs`、
> `scripts/mosh-extra-resources.cjs`、`electron/bridges/terminalBridge/moshSession.cjs`。
> 客户端本体在独立仓库 [binaricat/MoshCatty](https://github.com/binaricat/MoshCatty)
> (`moshcatty-*` releases);Netcatty 内已无 Cygwin 构建流水线 / FluentTerminal 回退。
## 关键设计差异(ET vs Mosh)
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
- **协议**:Mosh 需要 Node 做 SSH bootstrap + 抓 `MOSH CONNECT` + 换 PTY
(`moshHandshake` + `moshSession`)。**ET 不需要** —— `et` 客户端自己完成 SSH
引导 + 协议握手,我们只要把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有**
`etHandshake.cjs`。
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
- **纯二进制**:MoshCatty 与理想 ET 打包都是「每平台一个客户端文件」。Mosh 侧已
无 terminfo / Cygwin DLL 袋;`et` 同样本地不渲染终端。Windows 若动态链 CRT
才考虑可选 DLL 目录,否则只放 `et[.exe]`。
- **构建系统**:Mosh 客户端在 **MoshCatty** 仓库用 Rust 构建并发布;Netcatty 只
`fetch`。**ET** 用 CMake + Ninja + vcpkg
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
产物是单个 `et`(Windows `et.exe`)。
产物是单个 `et`(Windows `et.exe`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
## 命名约定(镜像 Mosh)
## 命名约定(镜像 Mosh / MoshCatty)
| Mosh | ET |
| Mosh (MoshCatty) | ET |
|------|----|
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
| `scripts/build-mosh/` | `scripts/build-et/` |
| 上游构建:`binaricat/MoshCatty` CI releases | `scripts/build-et/` + `build-et-binaries.yml` |
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| env `MOSH_BIN_RELEASE` / 仓库 `MoshCatty` / tag `moshcatty-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
---
@@ -52,7 +57,7 @@
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
校验非系统动态库(同 mosh 的 ldd 白名单)。
校验非系统动态库(ldd 白名单)。
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
@@ -60,13 +65,12 @@
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。纯客户端文件为主。
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
@@ -77,13 +81,12 @@
`scripts/*.test.cjs`(确认即可)。
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 四个构建 job + 一个
`release` job(dispatch 且 `release_tag` 非空时发布到 `Netcatty-et-bin`,附
`SHA256SUMS`)。`paths` 过滤指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、
`scripts/et-extra-resources.cjs`。env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh 的 `MOSH_BIN_RELEASE`)。
## Phase 2 — 运行时定位捆绑客户端
@@ -100,7 +103,7 @@
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
+55 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
+36
View File
@@ -46,6 +46,42 @@ test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer wi
});
});
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
const openedPayloads: unknown[] = [];
const localSession = sourceSession({
hostLabel: "Local Terminal",
hostname: "localhost",
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [localSession],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Local Terminal",
sourceSession: localSession,
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
+25 -2
View File
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
}
}
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
if (!hostId) return;
if (!isVaultInitialized) {
queueConnect(hostId);
return;
}
connectNow(hostId);
}
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
const hostIds = [...pendingHostIds];
setPendingHostIds([]);
hostIds.forEach((hostId: string) => connectNow(hostId));
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
@@ -727,7 +745,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
}
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
export function handleCreateLocalTerminalImpl(
getCtx: AppContextGetter,
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) {
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
@@ -742,6 +764,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
shellArgs: resolved?.args,
shellName,
shellIcon,
localStartDir: options?.localStartDir,
});
addConnectionLog({
sessionId,
+8 -5
View File
@@ -95,7 +95,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -294,13 +294,15 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
</VaultViewContainer>
<SftpViewMount
hosts={hosts}
hosts={terminalHosts}
writableHosts={hosts}
sessions={sessions}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
updateHosts={updateTerminalHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -315,7 +317,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
/>
<TerminalLayerMount
hosts={hosts}
hosts={terminalHosts}
portForwardingRules={portForwardingRules}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -390,7 +392,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
@@ -558,6 +560,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
/>
</Suspense>
</LazyLoadBoundary>
-5
View File
@@ -113,8 +113,3 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}
+14 -1
View File
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
hosts: hostsForSystemMenu,
});
}, 250);
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, sessions, portForwardingRules, workspaces]);
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
+65
View File
@@ -185,6 +185,71 @@ export const enAiMessages: Messages = {
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'External MCP',
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
'ai.externalMcp.mode': 'Availability mode',
'ai.externalMcp.mode.temporary': 'Temporary',
'ai.externalMcp.mode.persistent': 'Always on',
'ai.externalMcp.mode.description': 'Temporary mode auto-disables after idle timeout. Always-on restores External MCP when Netcatty starts.',
'ai.externalMcp.idleTimeout': 'Idle timeout',
'ai.externalMcp.idleTimeout.description': 'In temporary mode, disable External MCP after this many minutes with no MCP operations.',
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.usage.title': 'How to use',
'ai.externalMcp.usage.keepRunning': '1. Turn on External MCP and keep Netcatty running.',
'ai.externalMcp.usage.localhost': '2. Clients connect via the local launcher (127.0.0.1 only). Discovery is removed when you disable the switch.',
'ai.externalMcp.usage.permissions': '3. Write operations follow Settings → AI → Safety (observer / confirm / auto) and the command blocklist.',
'ai.externalMcp.usage.capabilities': '4. Full catalog tools are available: terminal, SFTP, Vault, and port forwarding. Secrets (passwords / private keys) are never returned.',
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission mode',
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
'ai.externalMcp.permissionMode.unknown': 'Unknown',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'Unavailable',
'ai.externalMcp.bridgeUnavailable': 'External MCP bridge unavailable',
'ai.externalMcp.copy': 'Copy',
'ai.externalMcp.copied': 'Copied',
'ai.externalMcp.copyFailed': 'Copy failed. Try copying manually.',
'ai.externalMcp.refresh': 'Refresh',
'ai.externalMcp.clientConfiguration': 'Client configuration',
'ai.externalMcp.clientConfiguration.description': 'Pick a client to one-click install, or copy CLI / config snippets.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI command',
'ai.externalMcp.configSnippet': 'Config snippet',
'ai.externalMcp.addToCodex': 'Add to Codex',
'ai.externalMcp.addToClaude': 'Add to Claude Code',
'ai.externalMcp.addToGrok': 'Add to Grok',
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
'ai.externalMcp.cursor.title': 'Cursor / other clients',
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
'ai.externalMcp.status.unavailable': 'Unavailable',
'ai.externalMcp.status.disabled': 'Disabled',
'ai.externalMcp.status.running': 'Running',
'ai.externalMcp.status.starting': 'Starting',
'ai.externalMcp.status.error': 'Error',
'ai.externalMcp.status.configured': 'Configured',
'ai.externalMcp.status.notConfigured': 'Not configured',
'ai.externalMcp.status.checking': 'Checking',
'ai.externalMcp.status.codexNotFound': 'Codex not found',
'ai.externalMcp.status.claudeNotFound': 'Claude Code not found',
'ai.externalMcp.status.grokNotFound': 'Grok not found',
'ai.externalMcp.status.conflict': 'Conflict',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
+35
View File
@@ -106,6 +106,21 @@ export const enCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
'settings.system.networkProxy.mode': 'Proxy mode',
'settings.system.networkProxy.mode.system': 'Use system proxy',
'settings.system.networkProxy.mode.direct': 'Direct (no proxy)',
'settings.system.networkProxy.mode.custom': 'Custom proxy',
'settings.system.networkProxy.url': 'Proxy URL',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': 'Supports http://, https://, and socks5:// host:port URLs. Proxy credentials in the URL are not supported.',
'settings.system.networkProxy.bypass': 'Bypass list',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': 'Comma-separated hosts that skip the proxy. Use <local> for loopback and LAN names.',
'settings.system.networkProxy.hint': 'Affects Google Drive / OneDrive / GitHub sync login and AI provider requests. Does not change SSH connections.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
@@ -397,9 +412,18 @@ export const enCoreMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': 'Show menu',
'settings.terminal.behavior.middleClick.paste': 'Paste',
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
'settings.terminal.behavior.wordSeparators': 'Word separators',
'settings.terminal.behavior.wordSeparators.desc':
'Characters treated as word boundaries for double-click selection. Spaces count too; add =, comma, or : for strings like uid=name.',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
@@ -488,6 +512,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
@@ -721,6 +747,12 @@ export const enCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.deleteConfirm.title': 'Delete "{name}"?',
'vault.deleteConfirm.desc': 'This action cannot be undone.',
'vault.deleteConfirm.packageDesc': 'This will remove the script package. Scripts inside it will stay saved and move out of the package.',
'vault.deleteConfirm.noteGroupDesc': 'This will remove the folder. Notes inside it will stay saved and move out of the folder.',
'vault.deleteConfirm.portForwardingDesc': 'This will delete the port forwarding rule.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
@@ -763,5 +795,8 @@ export const enCoreMessages: Messages = {
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
'deepLink.ssh.invalid': 'Invalid ssh:// link',
'deepLink.telnet.invalid': 'Invalid telnet:// link',
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
};
+5
View File
@@ -69,6 +69,11 @@ export const enScriptsMessages = {
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'OK',
'scripts.dialog.required': 'Required',
'scripts.dialog.numberInvalid': 'Enter a valid number',
'scripts.dialog.numberMin': 'Must be at least {min}',
'scripts.dialog.numberMax': 'Must be at most {max}',
'scripts.dialog.numberStep': 'Must use increments of {step}',
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
'scripts.dialog.retry': 'Retry',
'scripts.dialog.skip': 'Skip',
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
'systemManager.overview.memory': 'Memory',
'systemManager.overview.disk': 'Disk',
'systemManager.overview.network': 'Network',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} cores',
'systemManager.overview.load': 'Load',
'systemManager.overview.uptime': 'Uptime',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'SSH network latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
'systemManager.overview.topProcesses': 'Top memory processes',
'systemManager.overview.noData': 'No data',
'systemManager.overview.noDisks': 'No disk data',
'systemManager.overview.noInterfaces': 'No interface data',
'systemManager.overview.noTopProcesses': 'No process data',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
+7 -1
View File
@@ -88,7 +88,7 @@ export const enTerminalMessages: Messages = {
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.latency': 'Latency',
'terminal.serverStats.latency': 'SSH network latency',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
@@ -155,6 +155,8 @@ export const enTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
@@ -166,6 +168,9 @@ export const enTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
@@ -296,6 +301,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.s3.sessionToken': 'Session Token (optional)',
'cloudSync.s3.prefix': 'Key Prefix (optional)',
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
'cloudSync.s3.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.s3.showSecret': 'Show secrets',
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
'cloudSync.smb.title': 'SMB Settings',
+9 -3
View File
@@ -279,6 +279,9 @@ export const enVaultMessages: Messages = {
'sftp.picker.local.title': 'Local filesystem',
'sftp.picker.local.desc': 'Browse local files',
'sftp.picker.local.badge': 'Local',
'sftp.picker.connected.section': 'Connected',
'sftp.picker.connected.badge': 'Live',
'sftp.picker.connected.connecting': 'Connecting',
'sftp.picker.noMatch': 'No matching hosts',
'sftp.permissions.title': 'Edit Permissions',
'sftp.permissions.owner': 'Owner',
@@ -382,9 +385,12 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH links',
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.jmsDeepLink.title': 'JumpServer Links',
'settings.jmsDeepLink.enable': 'Handle jms:// links',
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
+64
View File
@@ -169,6 +169,70 @@ export const ruAiMessages: Messages = {
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.externalMcp.title': 'Внешний MCP',
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
'ai.externalMcp.mode': 'Режим доступности',
'ai.externalMcp.mode.temporary': 'Временный',
'ai.externalMcp.mode.persistent': 'Постоянный',
'ai.externalMcp.mode.description': 'Временный режим отключается после простоя. Постоянный восстанавливает External MCP при запуске Netcatty.',
'ai.externalMcp.idleTimeout': 'Таймаут простоя',
'ai.externalMcp.idleTimeout.description': 'В временном режиме отключить External MCP после указанного числа минут без MCP-операций.',
'ai.externalMcp.idleTimeout.minutes': 'мин',
'ai.externalMcp.usage.title': 'Как пользоваться',
'ai.externalMcp.usage.keepRunning': '1. Включите External MCP и держите Netcatty запущенным.',
'ai.externalMcp.usage.localhost': '2. Клиенты подключаются через локальный launcher (только 127.0.0.1). Discovery удаляется при отключении.',
'ai.externalMcp.usage.permissions': '3. Запись следует Settings → AI → Safety и списку блокировки команд.',
'ai.externalMcp.usage.capabilities': '4. Доступен полный catalog: терминал, SFTP, Vault и проброс портов. Секреты не возвращаются.',
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
'ai.externalMcp.permissionMode.unknown': 'Неизвестно',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'Недоступно',
'ai.externalMcp.bridgeUnavailable': 'Мост External MCP недоступен',
'ai.externalMcp.copy': 'Копировать',
'ai.externalMcp.copied': 'Скопировано',
'ai.externalMcp.copyFailed': 'Не удалось скопировать. Скопируйте вручную.',
'ai.externalMcp.refresh': 'Обновить',
'ai.externalMcp.clientConfiguration': 'Настройка клиента',
'ai.externalMcp.clientConfiguration.description': 'Выберите клиент для установки в один клик или скопируйте CLI / фрагмент конфига.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI-команда',
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
'ai.externalMcp.addToCodex': 'Добавить в Codex',
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
'ai.externalMcp.addToGrok': 'Добавить в Grok',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
'ai.externalMcp.status.unavailable': 'Недоступно',
'ai.externalMcp.status.disabled': 'Выключено',
'ai.externalMcp.status.running': 'Работает',
'ai.externalMcp.status.starting': 'Запуск',
'ai.externalMcp.status.error': 'Ошибка',
'ai.externalMcp.status.configured': 'Настроено',
'ai.externalMcp.status.notConfigured': 'Не настроено',
'ai.externalMcp.status.checking': 'Проверка',
'ai.externalMcp.status.codexNotFound': 'Codex не найден',
'ai.externalMcp.status.claudeNotFound': 'Claude Code не найден',
'ai.externalMcp.status.grokNotFound': 'Grok не найден',
'ai.externalMcp.status.conflict': 'Конфликт',
'ai.userSkills.title': 'Пользовательские skills',
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
'ai.userSkills.openFolder': 'Открыть папку skills',
+33
View File
@@ -106,6 +106,21 @@ export const ruCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
'settings.system.networkProxy.mode': 'Режим прокси',
'settings.system.networkProxy.mode.system': 'Системный прокси',
'settings.system.networkProxy.mode.direct': 'Напрямую (без прокси)',
'settings.system.networkProxy.mode.custom': 'Свой прокси',
'settings.system.networkProxy.url': 'URL прокси',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': 'Поддерживаются URL вида http://, https:// и socks5:// host:port. Учётные данные в URL прокси не поддерживаются.',
'settings.system.networkProxy.bypass': 'Список исключений',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': 'Хосты через запятую, для которых прокси не используется. <local> — localhost и локальные имена.',
'settings.system.networkProxy.hint': 'Влияет на вход в Google Drive / OneDrive / GitHub sync и запросы к AI-провайдерам. SSH-подключения не меняет.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Журналы сбоев',
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
@@ -374,9 +389,18 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
'settings.terminal.behavior.middleClick.paste': 'Вставить',
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.wordSeparators': 'Разделители слов',
'settings.terminal.behavior.wordSeparators.desc':
'Символы, которые считаются границами слова при выделении двойным щелчком. Пробел тоже учитывается; добавьте =, запятую или : для строк вроде uid=name.',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
@@ -725,11 +749,20 @@ export const ruCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
'vault.deleteConfirm.title': 'Удалить "{name}"?',
'vault.deleteConfirm.desc': 'Это действие нельзя отменить.',
'vault.deleteConfirm.packageDesc': 'Пакет скриптов будет удалён. Скрипты внутри него сохранятся и будут вынесены из пакета.',
'vault.deleteConfirm.noteGroupDesc': 'Папка будет удалена. Заметки внутри неё сохранятся и будут вынесены из папки.',
'vault.deleteConfirm.portForwardingDesc': 'Правило проброса портов будет удалено.',
'vault.managedSource.unmanage': 'Снять управление',
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
'vault.hosts.header.entries': 'Записей: {count}',
'vault.hosts.header.live': 'Активных: {count}',
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
'deepLink.telnet.invalid': 'Недопустимая ссылка telnet://',
'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer',
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
};
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
'systemManager.overview.memory': 'Память',
'systemManager.overview.disk': 'Диск',
'systemManager.overview.network': 'Сеть',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} ядер',
'systemManager.overview.load': 'Нагрузка',
'systemManager.overview.uptime': 'Время работы',
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
'systemManager.overview.duration.minutes': '{{minutes}} мин',
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Сетевая задержка SSH',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
'systemManager.overview.topProcesses': 'Процессы по памяти',
'systemManager.overview.noData': 'Нет данных',
'systemManager.overview.noDisks': 'Нет данных дисков',
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
+5
View File
@@ -109,6 +109,7 @@ export const ruTerminalMessages: Messages = {
'terminal.serverStats.disk': 'Использование диска (корень)',
'terminal.serverStats.diskDetails': 'Смонтированные диски',
'terminal.serverStats.network': 'Скорость сети',
'terminal.serverStats.latency': 'Сетевая задержка SSH',
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
'terminal.serverStats.noData': 'Данные недоступны',
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
@@ -175,6 +176,8 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
@@ -186,6 +189,7 @@ export const ruTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
@@ -313,6 +317,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
'cloudSync.s3.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
'cloudSync.s3.showSecret': 'Показать секреты',
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
'cloudSync.smb.title': 'Настройки SMB',
+9 -3
View File
@@ -314,6 +314,9 @@ export const ruVaultMessages: Messages = {
'sftp.picker.local.title': 'Локальная файловая система',
'sftp.picker.local.desc': 'Просмотр локальных файлов',
'sftp.picker.local.badge': 'Локально',
'sftp.picker.connected.section': 'Подключённые',
'sftp.picker.connected.badge': 'Онлайн',
'sftp.picker.connected.connecting': 'Подключение',
'sftp.picker.noMatch': 'Подходящие хосты не найдены',
'sftp.permissions.title': 'Изменить права доступа',
'sftp.permissions.owner': 'Владелец',
@@ -417,9 +420,12 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { enSystemManagerMessages } from "./en/systemManager";
import { enTerminalMessages } from "./en/terminal";
import { ruSystemManagerMessages } from "./ru/systemManager";
import { ruTerminalMessages } from "./ru/terminal";
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
import { zhTWVaultMessages } from "./zh-TW/vault";
test("SSH network latency is explicit in every locale and UI surface", () => {
const labels = [
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
] as const;
for (const [terminalMessages, systemManagerMessages, expected] of labels) {
assert.equal(terminalMessages["terminal.serverStats.latency"], expected);
assert.equal(systemManagerMessages["systemManager.overview.latency"], expected);
}
});
@@ -36,6 +36,29 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include network proxy labels", () => {
const keys = [
"settings.system.networkProxy.title",
"settings.system.networkProxy.description",
"settings.system.networkProxy.mode",
"settings.system.networkProxy.mode.system",
"settings.system.networkProxy.mode.direct",
"settings.system.networkProxy.mode.custom",
"settings.system.networkProxy.url",
"settings.system.networkProxy.url.placeholder",
"settings.system.networkProxy.url.desc",
"settings.system.networkProxy.bypass",
"settings.system.networkProxy.bypass.placeholder",
"settings.system.networkProxy.bypass.desc",
"settings.system.networkProxy.hint",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing network proxy labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
+65
View File
@@ -185,6 +185,71 @@ export const zhCNAiMessages: Messages = {
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
// External MCP
'ai.externalMcp.title': '对外 MCP',
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
'ai.externalMcp.mode': '可用模式',
'ai.externalMcp.mode.temporary': '临时',
'ai.externalMcp.mode.persistent': '常开',
'ai.externalMcp.mode.description': '临时模式会在空闲超时后自动关闭;常开模式会在 Netcatty 启动时恢复对外 MCP。',
'ai.externalMcp.idleTimeout': '空闲超时',
'ai.externalMcp.idleTimeout.description': '临时模式下,若超过该分钟数没有 MCP 操作,将自动关闭对外 MCP。',
'ai.externalMcp.idleTimeout.minutes': '分钟',
'ai.externalMcp.usage.title': '使用说明',
'ai.externalMcp.usage.keepRunning': '1. 打开对外 MCP 开关,并保持 Netcatty 运行。',
'ai.externalMcp.usage.localhost': '2. 客户端通过本地 launcher 连接(仅 127.0.0.1)。关闭开关后会删除 discovery。',
'ai.externalMcp.usage.permissions': '3. 写操作遵循「设置 → AI → 安全」(观察 / 确认 / 自动)以及命令黑名单。',
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:终端、SFTP、Vault、端口转发。密码 / 私钥不会返回。',
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
'ai.externalMcp.permissionMode.unknown': '未知',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': '不可用',
'ai.externalMcp.bridgeUnavailable': '对外 MCP 桥接不可用',
'ai.externalMcp.copy': '复制',
'ai.externalMcp.copied': '已复制',
'ai.externalMcp.copyFailed': '复制失败,请手动复制。',
'ai.externalMcp.refresh': '刷新',
'ai.externalMcp.clientConfiguration': '客户端配置',
'ai.externalMcp.clientConfiguration.description': '选择客户端一键安装,或复制 CLI / 配置片段。',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '配置片段',
'ai.externalMcp.addToCodex': '添加到 Codex',
'ai.externalMcp.addToClaude': '添加到 Claude Code',
'ai.externalMcp.addToGrok': '添加到 Grok',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
'ai.externalMcp.status.unavailable': '不可用',
'ai.externalMcp.status.disabled': '已关闭',
'ai.externalMcp.status.running': '运行中',
'ai.externalMcp.status.starting': '启动中',
'ai.externalMcp.status.error': '错误',
'ai.externalMcp.status.configured': '已配置',
'ai.externalMcp.status.notConfigured': '未配置',
'ai.externalMcp.status.checking': '检查中',
'ai.externalMcp.status.codexNotFound': '未找到 Codex',
'ai.externalMcp.status.claudeNotFound': '未找到 Claude Code',
'ai.externalMcp.status.grokNotFound': '未找到 Grok',
'ai.externalMcp.status.conflict': '冲突',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
+27
View File
@@ -104,6 +104,21 @@ export const zhCNCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
'settings.system.networkProxy.mode': '代理模式',
'settings.system.networkProxy.mode.system': '系统代理',
'settings.system.networkProxy.mode.direct': '直连(不使用代理)',
'settings.system.networkProxy.mode.custom': '自定义代理',
'settings.system.networkProxy.url': '代理地址',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': '支持 http://、https://、socks5:// 的 host:port 地址。代理 URL 中不支持账号密码。',
'settings.system.networkProxy.bypass': '绕过列表',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': '逗号分隔、不走代理的主机。可用 <local> 表示本机与局域网名称。',
'settings.system.networkProxy.hint': '影响 Google Drive / OneDrive / GitHub 同步登录与 AI 服务商请求,不会改变 SSH 连接。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
@@ -450,6 +465,12 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.deleteConfirm.title': '删除“{name}”?',
'vault.deleteConfirm.desc': '此操作不可撤销。',
'vault.deleteConfirm.packageDesc': '这会删除脚本包,包内脚本会保留,并移出该脚本包。',
'vault.deleteConfirm.noteGroupDesc': '这会删除文件夹,文件夹内笔记会保留,并移出该文件夹。',
'vault.deleteConfirm.portForwardingDesc': '这会删除该端口转发规则。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
@@ -492,6 +513,9 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
'deepLink.telnet.invalid': '无效的 telnet:// 链接',
'deepLink.jms.invalid': '无效的 JumpServer 链接',
'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -711,6 +735,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.picker.local.title': '本地文件系统',
'sftp.picker.local.desc': '浏览本地文件',
'sftp.picker.local.badge': '本地',
'sftp.picker.connected.section': '已连接',
'sftp.picker.connected.badge': '已连接',
'sftp.picker.connected.connecting': '连接中',
'sftp.picker.noMatch': '没有匹配的主机',
'sftp.permissions.title': '编辑权限',
'sftp.permissions.owner': '所有者',
@@ -69,6 +69,11 @@ export const zhCNScriptsMessages = {
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
'scripts.dialog.title': '脚本',
'scripts.dialog.ok': '确定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '请输入有效数字',
'scripts.dialog.numberMin': '不能小于 {min}',
'scripts.dialog.numberMax': '不能大于 {max}',
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
'scripts.dialog.waitForTimeoutTitle': '等待超时',
'scripts.dialog.retry': '重试',
'scripts.dialog.skip': '跳过',
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
'systemManager.overview.memory': '内存',
'systemManager.overview.disk': '磁盘',
'systemManager.overview.network': '网络',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核',
'systemManager.overview.load': '负载',
'systemManager.overview.uptime': '运行时间',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': 'SSH 网络延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
'systemManager.overview.topProcesses': '内存占用最高进程',
'systemManager.overview.noData': '暂无数据',
'systemManager.overview.noDisks': '暂无磁盘数据',
'systemManager.overview.noInterfaces': '暂无网卡数据',
'systemManager.overview.noTopProcesses': '暂无进程数据',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
+20 -3
View File
@@ -20,6 +20,9 @@ export const zhCNTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -101,9 +104,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.jmsDeepLink.title': 'JumpServer 链接',
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
@@ -247,9 +253,18 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': '显示菜单',
'settings.terminal.behavior.middleClick.paste': '粘贴',
'settings.terminal.behavior.middleClick.disabled': '无动作',
'settings.terminal.behavior.wordSeparators': '单词分隔符',
'settings.terminal.behavior.wordSeparators.desc':
'双击选择文本时作为边界的字符。空格也算;可添加 =、逗号或 :,方便选择 uid=name 里的部分内容。',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
@@ -332,6 +347,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
+4 -1
View File
@@ -382,7 +382,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.latency': '延迟',
'terminal.serverStats.latency': 'SSH 网络延迟',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
@@ -437,6 +437,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
'terminal.auth.certificate': '证书',
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
@@ -575,6 +577,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.s3.sessionToken': 'Session Token(可选)',
'cloudSync.s3.prefix': 'Key 前缀(可选)',
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
'cloudSync.s3.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.s3.showSecret': '显示密钥',
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
'cloudSync.smb.title': 'SMB 设置',
+64
View File
@@ -185,6 +185,70 @@ export const zhTWAiMessages: Messages = {
'ai.toolAccess.description': '選擇外部 Agent 存取 Netcatty 工作階段的方式。MCP 會公開內建伺服器,Skills + CLI 會引導 Agent 讀取本機 Skill 並呼叫 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.externalMcp.title': '對外 MCP',
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
'ai.externalMcp.mode': '可用模式',
'ai.externalMcp.mode.temporary': '暫時',
'ai.externalMcp.mode.persistent': '常開',
'ai.externalMcp.mode.description': '暫時模式會在閒置逾時後自動關閉;常開模式會在 Netcatty 啟動時還原對外 MCP。',
'ai.externalMcp.idleTimeout': '閒置逾時',
'ai.externalMcp.idleTimeout.description': '暫時模式下,若超過該分鐘數沒有 MCP 操作,將自動關閉對外 MCP。',
'ai.externalMcp.idleTimeout.minutes': '分鐘',
'ai.externalMcp.usage.title': '使用說明',
'ai.externalMcp.usage.keepRunning': '1. 開啟對外 MCP 開關,並保持 Netcatty 執行。',
'ai.externalMcp.usage.localhost': '2. 用戶端透過本機 launcher 連線(僅 127.0.0.1)。關閉開關後會刪除 discovery。',
'ai.externalMcp.usage.permissions': '3. 寫入操作遵循「設定 → AI → 安全」以及命令黑名單。',
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:終端機、SFTP、Vault、連接埠轉發。密碼 / 私鑰不會回傳。',
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用審批;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具審批介面。',
'ai.externalMcp.permissionMode.unknown': '未知',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': '無法使用',
'ai.externalMcp.bridgeUnavailable': '對外 MCP 橋接無法使用',
'ai.externalMcp.copy': '複製',
'ai.externalMcp.copied': '已複製',
'ai.externalMcp.copyFailed': '複製失敗,請手動複製。',
'ai.externalMcp.refresh': '重新整理',
'ai.externalMcp.clientConfiguration': '用戶端設定',
'ai.externalMcp.clientConfiguration.description': '選擇用戶端一鍵安裝,或複製 CLI / 設定片段。',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '設定片段',
'ai.externalMcp.addToCodex': '新增到 Codex',
'ai.externalMcp.addToClaude': '新增到 Claude Code',
'ai.externalMcp.addToGrok': '新增到 Grok',
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
'ai.externalMcp.status.unavailable': '無法使用',
'ai.externalMcp.status.disabled': '已關閉',
'ai.externalMcp.status.running': '執行中',
'ai.externalMcp.status.starting': '啟動中',
'ai.externalMcp.status.error': '錯誤',
'ai.externalMcp.status.configured': '已設定',
'ai.externalMcp.status.notConfigured': '未設定',
'ai.externalMcp.status.checking': '檢查中',
'ai.externalMcp.status.codexNotFound': '找不到 Codex',
'ai.externalMcp.status.claudeNotFound': '找不到 Claude Code',
'ai.externalMcp.status.grokNotFound': '找不到 Grok',
'ai.externalMcp.status.conflict': '衝突',
'ai.userSkills.title': '使用者 Skills',
'ai.userSkills.description': '開啟 Netcatty 的 Skills 資料夾以新增你自己的技能目錄。Netcatty 會自動掃描這些 skills,預設只注入輕量索引,只有在請求明顯命中某個 skill 時才展開正文。',
'ai.userSkills.openFolder': '開啟 Skills 資料夾',
+27
View File
@@ -104,6 +104,21 @@ export const zhTWCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他使用者或機器上加密的憑證無法在此處解密。請在目前裝置重新輸入並儲存憑證。',
'settings.system.credentials.portabilityHint': '雲同步可跨裝置,因為使用主金鑰加密;本機 safeStorage 加密僅綁定目前系統使用者/裝置。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '網路代理',
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
'settings.system.networkProxy.mode': '代理模式',
'settings.system.networkProxy.mode.system': '系統代理',
'settings.system.networkProxy.mode.direct': '直連(不使用代理)',
'settings.system.networkProxy.mode.custom': '自訂代理',
'settings.system.networkProxy.url': '代理位址',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': '支援 http://、https://、socks5:// 的 host:port 位址。代理 URL 中不支援帳號密碼。',
'settings.system.networkProxy.bypass': '略過清單',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': '逗號分隔、不走代理的主機。可用 <local> 表示本機與區域網路名稱。',
'settings.system.networkProxy.hint': '影響 Google Drive / OneDrive / GitHub 同步登入與 AI 服務商請求,不會改變 SSH 連線。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩潰日誌',
'settings.system.crashLogs.description': '檢視主程序錯誤日誌,協助診斷異常行為。',
@@ -450,6 +465,12 @@ export const zhTWCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
'vault.deleteConfirm.title': '刪除「{name}」?',
'vault.deleteConfirm.desc': '此操作不可復原。',
'vault.deleteConfirm.packageDesc': '這會刪除程式碼包,包內程式碼片段會保留,並移出該程式碼包。',
'vault.deleteConfirm.noteGroupDesc': '這會刪除資料夾,資料夾內筆記會保留,並移出該資料夾。',
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
'vault.managedSource.unmanage': '取消託管',
'vault.managedSource.unmanageSuccess': '已取消託管分組',
@@ -492,6 +513,9 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.empty.title': '設定你的主機',
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
'deepLink.telnet.invalid': '無效的 telnet:// 連結',
'deepLink.jms.invalid': '無效的 JumpServer 連結',
'deepLink.jms.unsupported': '不支援的 JumpServer 協定:{{protocol}}',
// Vault import
'vault.import.title': '新增資料到你的 Vault',
@@ -711,6 +735,9 @@ export const zhTWCoreMessages: Messages = {
'sftp.picker.local.title': '本機檔案系統',
'sftp.picker.local.desc': '瀏覽本機檔案',
'sftp.picker.local.badge': '本機',
'sftp.picker.connected.section': '已連線',
'sftp.picker.connected.badge': '已連線',
'sftp.picker.connected.connecting': '連線中',
'sftp.picker.noMatch': '沒有相符的主機',
'sftp.permissions.title': '編輯權限',
'sftp.permissions.owner': '所有者',
@@ -69,6 +69,11 @@ export const zhTWScriptsMessages = {
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
'scripts.dialog.title': '指令碼',
'scripts.dialog.ok': '確定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '請輸入有效數字',
'scripts.dialog.numberMin': '不能小於 {min}',
'scripts.dialog.numberMax': '不能大於 {max}',
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
'scripts.dialog.retry': '重試',
'scripts.dialog.skip': '跳過',
@@ -6,6 +6,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.noSession': '沒有使用中的終端工作階段。',
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
'systemManager.overview.loading': '正在載入系統概覽…',
'systemManager.overview.memory': '記憶體',
'systemManager.overview.disk': '磁碟',
'systemManager.overview.network': '網路',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核心',
'systemManager.overview.load': '負載',
'systemManager.overview.uptime': '執行時間',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
'systemManager.overview.system': '系統',
'systemManager.overview.kernel': '核心',
'systemManager.overview.swap': '交換區',
'systemManager.overview.latency': 'SSH 網路延遲',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體占用最高程序',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.processes.search': '搜尋程序…',
'systemManager.processes.command': '指令',
'systemManager.processes.user': '使用者',
+20 -3
View File
@@ -20,6 +20,9 @@ export const zhTWTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
@@ -101,9 +104,12 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側',
'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案',
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.jmsDeepLink.title': 'JumpServer 連結',
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自動同步到遠端',
@@ -247,9 +253,18 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': '顯示選單',
'settings.terminal.behavior.middleClick.paste': '貼上',
'settings.terminal.behavior.middleClick.disabled': '無動作',
'settings.terminal.behavior.wordSeparators': '單字分隔符',
'settings.terminal.behavior.wordSeparators.desc':
'雙擊選取文字時作為邊界的字元。空格也算;可加入 =、逗號或 :,方便選取 uid=name 裡的部分內容。',
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
'settings.terminal.behavior.bracketedPaste.desc':
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
@@ -332,6 +347,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
+4 -1
View File
@@ -382,7 +382,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
'terminal.serverStats.diskDetails': '已掛載磁碟',
'terminal.serverStats.network': '網路速度',
'terminal.serverStats.latency': '延遲',
'terminal.serverStats.latency': 'SSH 網路延遲',
'terminal.serverStats.networkDetails': '網路介面',
'terminal.serverStats.noData': '暫無資料',
'terminal.dragDrop.localTitle': '拖放以插入路徑',
@@ -437,6 +437,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
'terminal.auth.certificate': '憑證',
'terminal.auth.selectKey': '選擇金鑰',
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
@@ -575,6 +577,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.s3.sessionToken': 'Session Token(可選)',
'cloudSync.s3.prefix': 'Key 字首(可選)',
'cloudSync.s3.forcePathStyle': '強制使用 path-style URL(適用於 MinIO/R2 等)',
'cloudSync.s3.allowInsecure': '允許不安全的連線(忽略憑證錯誤)',
'cloudSync.s3.showSecret': '顯示金鑰',
'cloudSync.s3.validation.required': '端點、Region、Bucket、Access Key 與 Secret 必填。',
'cloudSync.smb.title': 'SMB 設定',
-5
View File
@@ -67,11 +67,6 @@ export const useActiveTabId = () => {
);
};
// Hook to get setter - never causes re-render
export const useSetActiveTabId = () => {
return activeTabStore.setActiveTabId;
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
+323
View File
@@ -0,0 +1,323 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
import {
hasPersistedAppearanceChanged,
resolveAppearanceStorageEvent,
resolveAppearanceSyncState,
resolveIncomingAppearanceValue,
type AppearanceState,
type AppearanceRenderSnapshot,
type StoredAppearanceValues,
} from "./appearanceSync.ts";
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from "../../infrastructure/config/storageKeys.ts";
const systemLight: AppearanceRenderSnapshot = {
theme: "system",
resolvedTheme: "light",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
let storedTheme: "light" | "dark" | "system" = "dark";
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
storedTheme = systemDark.theme;
}
assert.equal(storedTheme, "dark");
});
test("an explicit theme choice remains a persisted appearance change", () => {
assert.equal(
hasPersistedAppearanceChanged(systemLight, {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
}),
true,
);
});
test("an appearance IPC value wins over stale local storage for the changed key", () => {
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
"system",
"system",
(value): value is "light" | "dark" | "system" => (
value === "light" || value === "dark" || value === "system"
),
),
"dark",
);
// Non-matching keyed IPC must keep the current in-memory value, not storage.
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
"stale-from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"midnight",
);
// Full rehydrate (no incoming) still prefers storage.
assert.equal(
resolveIncomingAppearanceValue(
undefined,
STORAGE_KEY_UI_THEME_DARK,
"from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"from-storage",
);
});
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
const current: AppearanceState = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
// announced on the IPC payload, so the reducer must prefer the payload.
const darkUiSelection = {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
};
const nextDarkUi = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "midnight",
}, darkUiSelection);
assert.equal(nextDarkUi.darkUiThemeId, "github");
assert.equal(nextDarkUi.theme, "dark");
assert.equal(nextDarkUi.lightUiThemeId, "snow");
const lightUiSelection = {
key: STORAGE_KEY_UI_THEME_LIGHT,
value: "flexoki",
};
const nextLightUi = resolveAppearanceSyncState(current, {
...staleStored,
lightUiThemeId: "snow",
}, lightUiSelection);
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
const accentModeSelection = {
key: STORAGE_KEY_ACCENT_MODE,
value: "custom",
};
const nextAccentMode = resolveAppearanceSyncState(current, {
...staleStored,
accentMode: "theme",
}, accentModeSelection);
assert.equal(nextAccentMode.accentMode, "custom");
const customAccentSelection = {
key: STORAGE_KEY_COLOR,
value: "221.2 83.2% 53.3%",
};
const nextCustomAccent = resolveAppearanceSyncState(current, {
...staleStored,
customAccent: "208 100% 50%",
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "github",
});
assert.equal(noIncoming.darkUiThemeId, "github");
});
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
// Storage still holds the pre-change values for the entire sequence.
const initial: AppearanceState = {
theme: "system",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
const staleStored: StoredAppearanceValues = { ...initial };
let next = resolveAppearanceSyncState(initial, staleStored, {
key: STORAGE_KEY_THEME,
value: "dark",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "midnight");
// Later theme-id message must not revert theme back to the stale stored System.
next = resolveAppearanceSyncState(next, staleStored, {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "github");
assert.equal(next.lightUiThemeId, "snow");
assert.equal(next.accentMode, "theme");
});
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
const initialState: AppearanceState = {
theme: systemLight.theme,
lightUiThemeId: systemLight.lightUiThemeId,
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
userIntent: idleThemeUserIntent(),
settings: {
terminalThemeId: "netcatty-dark",
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
followAppTerminalTheme: true,
resolvedTheme,
lightUiThemeId: appearance.lightUiThemeId,
darkUiThemeId: appearance.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
},
customThemes: [],
})
);
const persistRender = (
stored: StoredAppearanceValues,
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): StoredAppearanceValues => {
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
return {
theme: current.theme,
lightUiThemeId: current.lightUiThemeId,
darkUiThemeId: current.darkUiThemeId,
accentMode: current.accentMode,
customAccent: current.customAccent,
};
};
let stored: StoredAppearanceValues = { ...initialState };
let main = { ...initialState };
let detachedTerminal = { ...initialState };
const initialMainTerminal = terminalAppearance(main, "light");
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
const settingsDark: AppearanceRenderSnapshot = {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
};
stored = persistRender(stored, systemLight, settingsDark);
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
// A stale peer receives an OS color event before the Dark IPC message. Its
// semantic choice is still System, so it must not write System back.
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
// Main windows run the production IPC reducer. Deliberately pass its lagging
// System storage read so only the ordered Dark payload can win.
main = resolveAppearanceSyncState(main, {
...stored,
theme: "system",
}, darkSelection);
// Detached terminal and tray renderers run the production storage-event
// reducer because they are not direct IPC broadcast targets.
const detachedUpdate = resolveAppearanceStorageEvent(
detachedTerminal,
darkSelection.key,
String(stored.theme),
);
detachedTerminal = detachedUpdate.next;
const finalMainTerminal = terminalAppearance(main, "dark");
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
assert.equal(stored.theme, "dark");
assert.equal(settingsDark.theme, "dark");
assert.equal(main.theme, "dark");
assert.equal(detachedUpdate.handled, true);
assert.equal(detachedTerminal.theme, "dark");
assert.equal(initialMainTerminal.theme.type, "light");
assert.equal(initialDetachedTerminal.theme.type, "light");
assert.equal(finalMainTerminal.theme.type, "dark");
assert.equal(finalDetachedTerminal.theme.type, "dark");
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
});
test("the race guards are wired into the real settings paths", () => {
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
);
assert.match(
stateSource,
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
);
assert.match(
stateSource,
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
);
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
});
+176
View File
@@ -0,0 +1,176 @@
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
export type AppearanceState = {
theme: "light" | "dark" | "system";
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
};
export type AppearanceRenderSnapshot = {
theme: AppearanceState["theme"];
resolvedTheme: "light" | "dark";
} & Omit<AppearanceState, "theme">;
export type AppearanceSyncEvent = {
key: string;
value: unknown;
};
export type StoredAppearanceValues = {
theme: unknown;
lightUiThemeId: unknown;
darkUiThemeId: unknown;
accentMode: unknown;
customAccent: unknown;
};
export type AppearanceStorageEventResolution = {
handled: boolean;
next: AppearanceState;
};
export function hasPersistedAppearanceChanged(
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): boolean {
return previous.theme !== current.theme
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent;
}
export function resolveIncomingAppearanceValue<T>(
incoming: AppearanceSyncEvent | undefined,
key: string,
storedValue: T,
currentValue: T,
isValid: (value: unknown) => value is T,
): T {
if (incoming?.key === key && isValid(incoming.value)) {
return incoming.value;
}
// Keyed IPC updates only trust the announced key. Non-matching fields keep
// the in-memory current value so sequential notifies for one multi-field
// change cannot clobber each other with a still-stale storage read.
// Full rehydrate (no incoming) continues to prefer shared storage.
if (incoming) {
return currentValue;
}
return storedValue;
}
export function resolveAppearanceSyncState(
current: AppearanceState,
stored: StoredAppearanceValues,
incoming?: AppearanceSyncEvent,
): AppearanceState {
const theme = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
stored.theme,
current.theme,
isValidTheme,
);
const lightUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_LIGHT,
stored.lightUiThemeId,
current.lightUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('light', value),
);
const darkUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
stored.darkUiThemeId,
current.darkUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('dark', value),
);
const accentMode = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_ACCENT_MODE,
stored.accentMode,
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const customAccent = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_COLOR,
stored.customAccent,
current.customAccent,
(value): value is string => typeof value === 'string' && isValidHslToken(value),
);
return {
theme: isValidTheme(theme) ? theme : current.theme,
lightUiThemeId: typeof lightUiThemeId === 'string' && isValidUiThemeId('light', lightUiThemeId)
? lightUiThemeId
: current.lightUiThemeId,
darkUiThemeId: typeof darkUiThemeId === 'string' && isValidUiThemeId('dark', darkUiThemeId)
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
? customAccent.trim()
: current.customAccent,
};
}
export function resolveAppearanceStorageEvent(
current: AppearanceState,
key: string | null,
newValue: string | null,
): AppearanceStorageEventResolution {
if (key === STORAGE_KEY_THEME) {
return {
handled: true,
next: newValue && isValidTheme(newValue) ? { ...current, theme: newValue } : current,
};
}
if (key === STORAGE_KEY_UI_THEME_LIGHT) {
return {
handled: true,
next: newValue && isValidUiThemeId('light', newValue)
? { ...current, lightUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_UI_THEME_DARK) {
return {
handled: true,
next: newValue && isValidUiThemeId('dark', newValue)
? { ...current, darkUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_ACCENT_MODE) {
return {
handled: true,
next: newValue === 'theme' || newValue === 'custom'
? { ...current, accentMode: newValue }
: current,
};
}
if (key === STORAGE_KEY_COLOR) {
return {
handled: true,
next: newValue && isValidHslToken(newValue)
? { ...current, customAccent: newValue.trim() }
: current,
};
}
return { handled: false, next: current };
}
-14
View File
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
-18
View File
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
);
};
/**
* Get font loading state
*/
export const useFontsLoading = (): boolean => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading
);
};
/**
* Get font by ID with fallback - useful for components that need a specific font
*/
export const useFontById = (fontId: string): TerminalFont => {
const fonts = useAvailableFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
/**
* Initialize fonts eagerly (call at app startup)
*/
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LOCAL_TERMINAL_HOST_ID,
createLocalTerminalSession,
} from "./sessionFactories.ts";
test("createLocalTerminalSession uses a stable hostId across sessions", () => {
const a = createLocalTerminalSession("session-a");
const b = createLocalTerminalSession("session-b");
assert.equal(a.hostId, LOCAL_TERMINAL_HOST_ID);
assert.equal(b.hostId, LOCAL_TERMINAL_HOST_ID);
assert.notEqual(a.id, b.id);
assert.equal(a.protocol, "local");
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { createHostTerminalSession } from "./sessionFactories";
const host = (overrides: Partial<Host>): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "alice",
port: 22,
group: "",
tags: [],
os: "linux",
protocol: "ssh",
createdAt: 1,
...overrides,
});
test("createHostTerminalSession keeps telnet deep-link default port for ssh hosts with telnet enabled", () => {
const connectionHost = buildTelnetDeepLinkConnectionHost(
host({
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
}),
);
const session = createHostTerminalSession("session-1", connectionHost);
assert.equal(session.protocol, "telnet");
assert.equal(session.port, 23);
});
+14 -1
View File
@@ -6,14 +6,24 @@ export interface LocalTerminalOptions {
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
}
/**
* Stable hostId for all Local Terminal sessions.
*
* Autocomplete command history is keyed by hostId. Using `local-${sessionId}`
* made every new Local Terminal look like a brand-new host, so history
* suggestions never accumulated across opens (issue #2037).
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
): TerminalSession => ({
id: sessionId,
hostId: `local-${sessionId}`,
hostId: LOCAL_TERMINAL_HOST_ID,
hostLabel: options?.shellName || "Local Terminal",
hostname: "localhost",
username: "local",
@@ -24,6 +34,7 @@ export const createLocalTerminalSession = (
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
localStartDir: options?.localStartDir,
});
export const createSerialTerminalSession = (
@@ -86,5 +97,7 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
};
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import {
applyCloseSessionToSessions,
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
resolveActiveTabAfterCloseSession,
} from "./sessionWorkspaceDetach";
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
@@ -121,3 +123,57 @@ test("closing a workspace session dissolves the workspace when one terminal rema
["log-1", "s2", "session-3"],
);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1"), session("s2")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
["s2", undefined],
]);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
}),
"s2",
);
});
test("closing the last workspace session does not invent a surviving terminal", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions, []);
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
assert.equal(layoutResult.lastRemainingSessionId, undefined);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
}),
"vault",
);
});
@@ -101,6 +101,59 @@ export function closeSessionWorkspaceLayoutState(
};
}
/**
* Apply a close-session action to the session list using the layout result.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
}
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
}: {
currentActiveTabId: string | null;
closedSessionId: string;
workspaceId: string | undefined;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
}): string | null {
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId).slice(-1)[0];
const fallback = layoutResult.lastRemainingSessionId
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
return fallback;
}
return null;
}
export function detachSessionFromWorkspaceState({
sessions,
workspaces,
+28 -7
View File
@@ -21,6 +21,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -43,19 +44,25 @@ import {
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
areHttpNetworkProxySettingsEqual,
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
import type { AppearanceSyncEvent } from './appearanceSync';
interface UseSettingsIpcSyncParams {
enabled?: boolean;
syncAppearanceFromStorage: () => void;
syncAppearanceFromStorage: (incoming?: AppearanceSyncEvent) => void;
syncCustomCssFromStorage: () => void;
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
@@ -73,13 +80,15 @@ interface UseSettingsIpcSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -113,6 +122,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -120,6 +130,7 @@ export function useSettingsIpcSync({
setWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -146,7 +157,7 @@ export function useSettingsIpcSync({
key === STORAGE_KEY_ACCENT_MODE ||
key === STORAGE_KEY_COLOR
) {
syncAppearanceFromStorage();
syncAppearanceFromStorage({ key, value });
return;
}
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
@@ -218,6 +229,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setSshDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setJmsDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -233,9 +247,8 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
@@ -244,6 +257,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
const next = normalizeHttpNetworkProxySettings(value);
setHttpNetworkProxy((prev) => (
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
@@ -292,6 +311,7 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAutoUpdateEnabled,
setHttpNetworkProxy,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
@@ -304,6 +324,7 @@ export function useSettingsIpcSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -92,6 +92,7 @@ export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
+25 -42
View File
@@ -3,9 +3,7 @@ import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSetting
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_COLOR,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -17,6 +15,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
@@ -42,22 +41,16 @@ import {
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { resolveAppearanceStorageEvent } from './appearanceSync';
import {
clampWindowOpacity,
isValidHslToken,
isValidTheme,
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
@@ -101,6 +94,7 @@ interface UseSettingsStorageSyncParams {
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
jmsDeepLinkEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -144,8 +138,9 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -162,7 +157,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -171,7 +166,7 @@ export function useSettingsStorageSync({
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -185,7 +180,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
@@ -195,7 +190,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -204,30 +199,14 @@ export function useSettingsStorageSync({
if (!enabled) return;
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
const appearance = resolveAppearanceStorageEvent(s, e.key, e.newValue);
if (appearance.handled) {
if (appearance.next.theme !== s.theme) setTheme(appearance.next.theme);
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
return;
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== s.customCSS) {
@@ -365,6 +344,12 @@ export function useSettingsStorageSync({
setSshDeepLinkEnabledState(newValue);
}
}
if (e.key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.jmsDeepLinkEnabled) {
setJmsDeepLinkEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -465,10 +450,7 @@ export function useSettingsStorageSync({
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(newValue);
}
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
@@ -514,6 +496,7 @@ export function useSettingsStorageSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -1,63 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
const commonArgs = {
terminalThemeId: "netcatty-dark",
customThemes: [],
resolvedTheme: "dark" as const,
lightUiThemeId: "snow",
darkUiThemeId: "github",
accentMode: "theme" as const,
customAccent: "",
};
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
pendingFollowAppTerminalThemeId: "system-flexoki-light",
resolvedTheme: "dark",
lightUiThemeId: "flexoki",
darkUiThemeId: "github",
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-flexoki-light");
});
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-github-dark");
});
test("manual terminal theme uses the active per-mode pick", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: false,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "dracula");
});
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
terminalThemeId: "dracula",
followAppTerminalTheme: false,
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
});
assert.equal(theme.id, "dracula");
});
@@ -1,63 +0,0 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
import {
applyCustomAccentToTerminalTheme,
resolveFollowAppTerminalThemeId,
resolveManualTerminalThemeId,
} from '../../domain/terminalAppearance';
interface ResolveCurrentTerminalThemeParams {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
customThemes: TerminalTheme[];
followAppTerminalTheme: boolean;
pendingFollowAppTerminalThemeId?: string | null;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
}
export function resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
pendingFollowAppTerminalThemeId = null,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
if (followAppTerminalTheme) {
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = getBuiltinTerminalThemeById(followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const manualThemeId = resolveManualTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|| customThemes.find(t => t.id === manualThemeId)
|| getBuiltinTerminalThemeById(terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
+54 -33
View File
@@ -4,7 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildSftpReuseCredentials, useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
@@ -421,7 +421,6 @@ export const useSftpConnections = ({
}
try {
const credentials = getHostCredentials(host);
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
@@ -436,47 +435,69 @@ export const useSftpConnections = ({
);
};
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
let credentials: NetcattySSHOptions | null = null;
let sftpId: string | undefined;
if (hasKey) {
// Live Connected rows: try reuse with endpoint-only options first so
// missing/undecryptable vault credentials do not block an already-
// authenticated terminal session. Fall back to full credentials if
// reuse fails (bridge may also fall through to a fresh connect).
if (options?.sourceSessionId && !host.sftpSudo) {
const reuseCredentials = buildSftpReuseCredentials(host, options.sourceSessionId);
try {
const keyFirstCredentials = {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
...reuseCredentials,
});
credentials = reuseCredentials;
} catch {
sftpId = undefined;
}
}
if (!sftpId) {
credentials = getHostCredentials(host);
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
} else {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
if (!sftpId || !credentials) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
if (!isTargetConnectionCurrent()) {
@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import {
buildSftpHostCredentials,
buildSftpReuseCredentials,
} from "./useSftpHostCredentials.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
@@ -14,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
...overrides,
});
test("buildSftpReuseCredentials only needs the live endpoint and sourceSessionId", () => {
const credentials = buildSftpReuseCredentials(
host({ hostname: "live.example.com", username: "alice", port: 2222 }),
"session-live",
);
assert.deepEqual(credentials, {
hostname: "live.example.com",
username: "alice",
port: 2222,
sourceSessionId: "session-live",
reuseOnly: true,
sudo: false,
});
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
@@ -30,6 +30,23 @@ interface UseSftpHostCredentialsParams {
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
}
/**
* Minimal options for reusing a live terminal SSH connection for SFTP.
* Must not require vault password/key material — the source session is already
* authenticated. Endpoint fields must match the session for findReusableSession.
*/
export const buildSftpReuseCredentials = (
host: Pick<Host, "hostname" | "username" | "port">,
sourceSessionId: string,
): NetcattySSHOptions => ({
hostname: host.hostname,
username: host.username || "root",
port: host.port || 22,
sourceSessionId,
reuseOnly: true,
sudo: false,
});
export const buildSftpHostCredentials = ({
host,
hosts,
+63 -7
View File
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
dirCacheTtlMs: number;
}
export type SftpNavigateResult = "reached" | "failed" | "aborted";
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
options?: SftpNavigateOptions,
): Promise<SftpNavigateResult> => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
return "aborted";
}
if (options?.shouldApply?.() === false) {
return "aborted";
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
const previousFilter = confirmed.filter;
const previousError = pane.error;
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
const getTargetPane = () => {
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
};
const shouldApplyNavigationState = () => (
getTargetPane()?.connection?.id === connectionId
&& (options?.shouldApply?.() ?? true)
);
const restoreConfirmedStateIfCurrent = () => {
if (!isTargetRequestCurrent()) return;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
filter: previousFilter,
error: previousError,
loading: false,
};
});
};
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
clearCacheForConnection(pane.connection.id);
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
if (options?.tabId) {
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
if (navSeqRef.current[side] !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
if (!isTargetRequestCurrent()) {
// This tab also has a newer navigation — drop completely.
return "aborted";
return "superseded";
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
dirCacheRef.current.set(cacheKey, {
files,
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
return "reached";
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return "aborted";
if (!isTargetRequestCurrent()) {
return "superseded";
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
let navigationFailed = false;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
+64 -7
View File
@@ -6,19 +6,34 @@ import {
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
shouldBroadcastWindowOpacityChange,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
@@ -31,9 +46,11 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
@@ -106,17 +123,57 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
useEffect(() => {
if (!enabled) return;
const normalized = normalizeHttpNetworkProxySettings(httpNetworkProxy);
localStorageAdapter.write(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
const bridge = netcattyBridge.get();
if (bridge?.setHttpNetworkProxy) {
// Apply to main process; empty custom is treated as system there.
// Persist draft custom+empty so the URL field remains visible.
bridge.setHttpNetworkProxy(normalized).catch((err) => {
console.warn('[NetworkProxy] Failed to apply HTTP network proxy:', err);
});
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
// Never let a stale effect overwrite a newer revision already on disk.
const stored = parseWindowOpacityRecord(
localStorageAdapter.readString(STORAGE_KEY_WINDOW_OPACITY),
);
if (
shouldApplyWindowOpacityRecord(stored, windowOpacityRecord)
|| stored.version === windowOpacityRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_WINDOW_OPACITY,
serializeWindowOpacityRecord(windowOpacityRecord),
);
}
const decision = shouldBroadcastWindowOpacityChange(
windowOpacityMutationSourceRef.current,
persistMountedRef.current,
);
windowOpacityMutationSourceRef.current = decision.nextSource;
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
}, [
enabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
notifySettingsChanged,
persistMountedRef,
]);
// Persist and sync app icon variant
useEffect(() => {
@@ -0,0 +1,127 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
isTerminalAutoReconnectEnabled,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "./terminalAutoReconnect";
const sshHost = {
protocol: "ssh" as const,
hostname: "example.com",
};
test("terminal auto reconnect uses a five second retry delay", () => {
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
});
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
});
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: false,
}),
false,
);
});
test("normal shell exits do not auto reconnect", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "exited", exitCode: 0 },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
{ protocol: "telnet" as const, hostname: "router.local" },
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
];
for (const host of variants) {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "error", error: "connection reset" },
host,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("an active auto reconnect loop continues after failed retry attempts", () => {
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
loopActive: true,
}),
true,
);
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: false },
loopActive: true,
}),
false,
);
});
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: true,
isHibernated: false,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: true,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: false,
}),
false,
);
});
@@ -0,0 +1,66 @@
import type { TerminalSettings } from "../../domain/models";
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
type AutoReconnectHost = {
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
hostname?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
};
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
return settings?.sshAutoReconnectEnabled === true;
}
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
const protocol = host.protocol ?? "ssh";
return (
protocol === "ssh" &&
host.hostname !== "localhost" &&
host.moshEnabled !== true &&
host.etEnabled !== true
);
}
export function shouldAutoReconnectAfterExit({
evt,
host,
terminalSettings,
hasEverConnected,
}: {
evt: TerminalSessionExitEvent;
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
hasEverConnected: boolean;
}): boolean {
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive,
}: {
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
loopActive: boolean;
}): boolean {
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
}
export function canAttemptTerminalAutoReconnect({
hasTerminalRuntime,
isHibernated,
}: {
hasTerminalRuntime: boolean;
isHibernated: boolean;
}): boolean {
return hasTerminalRuntime || isHibernated;
}
@@ -49,6 +49,21 @@ test("split session clones reuse only connected SSH sources", () => {
);
});
test("session clones preserve the ephemeral-host marker", () => {
assert.equal(
createSplitTerminalSessionClone(session({ ephemeralHost: true }), { id: "split-1" }).ephemeralHost,
true,
);
assert.equal(
createCopiedTerminalSessionClone(session({ ephemeralHost: true }), { id: "copy-1" }).ephemeralHost,
true,
);
assert.equal(
createSplitTerminalSessionClone(session(), { id: "split-2" }).ephemeralHost,
undefined,
);
});
test("copy session clones reuse SSH sources and preserve serial config", () => {
const copied = createCopiedTerminalSessionClone(
session({
@@ -60,3 +75,19 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});
test("split and copy session clones preserve local start directory", () => {
const source = session({
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
assert.equal(
createSplitTerminalSessionClone(source, { id: "split-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
assert.equal(
createCopiedTerminalSessionClone(source, { id: "copy-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
});
@@ -43,8 +43,10 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
localStartDir: session.localStartDir,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};
-18
View File
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
import { readExternalMcpStoredEnabled } from './useExternalMcpToggleState';
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
type UseExternalMcpSessionSyncOptions = {
sessions: TerminalSession[];
sessionHostsMap?: Map<string, Host>;
hosts: Host[];
portForwardingRules: PortForwardingRule[];
};
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
// Peer session windows and settings-only windows must not own the
// app-wide External MCP scope — they only see a partial session set.
if (hash.startsWith('#/session-window')) return false;
if (hash.startsWith('#/settings')) return false;
return true;
}
/**
* Keep the reserved External MCP scope aligned with every live terminal
* session, independent of whether the Catty AI side panel / TerminalLayer
* has mounted yet.
*/
export function useExternalMcpSessionSync({
sessions,
sessionHostsMap,
hosts,
portForwardingRules,
}: UseExternalMcpSessionSyncOptions) {
const [enabledTick, setEnabledTick] = useState(0);
const enabled = useMemo(() => {
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
const handleStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
bump();
};
const handleLocalStateChanged = (event: Event) => {
const detail = (event as CustomEvent<{ key?: string }>).detail;
if (detail?.key && detail.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
bump();
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
};
}, []);
const payload = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const hostById = new Map(hosts.map((host) => [host.id, host]));
return sessions.map((session) => {
const host = sessionHostsMap?.get(session.id)
|| (session.hostId ? hostById.get(session.hostId) : undefined);
return buildAITerminalSessionInfo(session, host, localOs, {
allHosts: hosts,
portForwardingRules,
});
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [enabled, payload]);
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
createExternalMcpStartupSyncPlan,
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
shouldStartExternalMcpOnStartup,
} from './useExternalMcpToggleState.ts';
describe('useExternalMcpToggleState helpers', () => {
it('normalizes mode and idle timeout', () => {
assert.equal(normalizeExternalMcpMode('persistent'), 'persistent');
assert.equal(normalizeExternalMcpMode('other'), 'temporary');
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
});
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: false, mode: 'persistent' }), false);
});
it('startup sync plan clears temporary stored enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'temporary',
idleTimeoutMinutes: 15,
});
assert.equal(plan.runtimeEnabled, false);
assert.equal(plan.storedEnabled, false);
assert.equal(plan.shouldPersistStoredEnabled, true);
assert.equal(plan.config.idleTimeoutMinutes, 15);
});
it('startup sync plan keeps persistent enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'persistent',
idleTimeoutMinutes: 20,
});
assert.equal(plan.runtimeEnabled, true);
assert.equal(plan.storedEnabled, true);
assert.equal(plan.shouldPersistStoredEnabled, false);
});
});
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from 'react';
import {
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
};
type ExternalMcpBridge = {
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
externalMcpGetStatus?: () => Promise<{ ok?: boolean; enabled?: boolean } | undefined>;
};
export type ExternalMcpStartupSyncPlan = {
config: ExternalMcpConfig;
runtimeEnabled: boolean;
storedEnabled: boolean;
shouldPersistStoredEnabled: boolean;
};
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
return value === 'persistent' ? 'persistent' : 'temporary';
}
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
return Math.min(
MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.max(
MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.round(value ?? DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
),
);
}
export function readExternalMcpStoredEnabled(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) ?? false;
}
export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
}: {
enabled: boolean;
mode: ExternalMcpMode;
}): boolean {
return mode === 'persistent' && enabled;
}
export function readExternalMcpStartupEnabled(): boolean {
return shouldStartExternalMcpOnStartup({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
});
}
export function createExternalMcpStartupSyncPlan({
enabled,
mode,
idleTimeoutMinutes,
}: {
enabled: boolean;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
return {
config: {
mode,
idleTimeoutMinutes,
},
runtimeEnabled,
storedEnabled,
shouldPersistStoredEnabled: storedEnabled !== enabled,
};
}
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
return createExternalMcpStartupSyncPlan({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
});
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
});
}
/**
* App-startup reconcile: only persistent+enabled starts the runtime.
* Temporary mode never auto-starts, and we clear a stale stored enabled flag
* so Settings remounts cannot accidentally re-enable temporary mode.
*/
export function syncExternalMcpStartupState(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): ExternalMcpStartupSyncPlan {
const plan = readExternalMcpStartupSyncPlan();
void bridge?.externalMcpSetConfig?.(plan.config);
if (plan.shouldPersistStoredEnabled) {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}
void bridge?.externalMcpSetEnabled?.(plan.runtimeEnabled);
return plan;
}
export function useExternalMcpToggleState() {
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
// decides whether temporary mode should clear/persist and start the runtime.
const [enabled, setEnabledRaw] = useState<boolean>(() => readExternalMcpStoredEnabled());
const persistEnabled = useCallback((nextEnabled: boolean) => {
setEnabledRaw(nextEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, nextEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}, []);
const setEnabled = useCallback((nextEnabled: boolean) => {
persistEnabled(nextEnabled);
void netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled);
}, [persistEnabled]);
useEffect(() => {
const syncFromStorage = () => {
const nextEnabled = readExternalMcpStoredEnabled();
setEnabledRaw(nextEnabled);
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage();
};
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage();
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
useEffect(() => {
if (!enabled) return;
const syncRuntimeStatus = async () => {
try {
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
if (status?.ok && !status.enabled) {
persistEnabled(false);
}
} catch {
// Keep the user's stored switch state during transient bridge errors.
}
};
const intervalId = window.setInterval(() => {
void syncRuntimeStatus();
}, 30000);
void syncRuntimeStatus();
return () => window.clearInterval(intervalId);
}, [enabled, persistEnabled]);
return { enabled, setEnabled };
}
@@ -43,6 +43,7 @@ test("main-window input focus recovery ignores generic focus and visible visibil
let cancelFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
let focusRequestedHandler: Listener | null = null;
let willHideHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
@@ -63,6 +64,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
shownHandler = cb;
return () => { shownHandler = null; };
},
onWindowFocusRequested(cb: Listener) {
focusRequestedHandler = cb;
return () => { focusRequestedHandler = null; };
},
onWindowWillHide(cb: Listener) {
willHideHandler = cb;
return () => { willHideHandler = null; };
@@ -85,6 +90,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
shownHandler?.();
assert.equal(scheduleFocusCalls, 0);
focusRequestedHandler?.();
assert.equal(scheduleFocusCalls, 1);
willHideHandler?.();
@@ -94,6 +103,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
shownHandler?.();
assert.equal(scheduleFocusCalls, 1);
focusRequestedHandler?.();
assert.equal(scheduleFocusCalls, 2);
visibilityState = "hidden";
@@ -105,11 +118,11 @@ test("main-window input focus recovery ignores generic focus and visible visibil
cleanup();
});
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
test("main-window input focus recovery retries an explicit foreground request when visibility catches up", () => {
let visibilityState: DocumentVisibilityState = "hidden";
let scheduleFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
let focusRequestedHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
@@ -123,9 +136,9 @@ test("main-window input focus recovery retries an explicit show when visibility
},
},
bridge: {
onWindowShown(cb: Listener) {
shownHandler = cb;
return () => { shownHandler = null; };
onWindowFocusRequested(cb: Listener) {
focusRequestedHandler = cb;
return () => { focusRequestedHandler = null; };
},
},
scheduleFocus: () => {
@@ -135,7 +148,7 @@ test("main-window input focus recovery retries an explicit show when visibility
},
);
shownHandler?.();
focusRequestedHandler?.();
assert.equal(scheduleFocusCalls, 0);
@@ -152,12 +165,13 @@ test("main-window input focus recovery retries an explicit show when visibility
cleanup();
});
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
test("useMainWindowInputFocusRecovery restores input focus only after explicit foreground IPC", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
assert.match(source, /visibilityState !== "visible"/);
assert.match(source, /scheduleFocus\(\)/);
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
assert.match(source, /onWindowFocusRequested\?\.\(\(\) => \{\s*pendingExplicitFocusRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
assert.doesNotMatch(source, /onWindowShown\?\.\(\(\) => \{[\s\S]*recoverFocus\(\)/);
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
});
@@ -26,6 +26,7 @@ type MainWindowInputFocusRecoveryWindow = {
type MainWindowInputFocusRecoveryBridge = {
onWindowShown?: (callback: Listener) => Listener;
onWindowFocusRequested?: (callback: Listener) => Listener;
onWindowWillHide?: (callback: Listener) => Listener;
};
@@ -53,7 +54,7 @@ export function startMainWindowInputFocusRecovery(
} = dependencies;
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
let pendingExplicitShowRecovery = false;
let pendingExplicitFocusRecovery = false;
const cancelPendingFocusRecovery = () => {
pendingFocusRecovery?.cancel();
@@ -62,14 +63,14 @@ export function startMainWindowInputFocusRecovery(
const recoverFocus = (): boolean => {
if (documentRef.visibilityState !== "visible") return false;
pendingExplicitShowRecovery = false;
pendingExplicitFocusRecovery = false;
cancelPendingFocusRecovery();
pendingFocusRecovery = scheduleFocus();
return true;
};
const dismissTransientUi = () => {
pendingExplicitShowRecovery = false;
pendingExplicitFocusRecovery = false;
cancelPendingFocusRecovery();
onPageHidden?.();
};
@@ -79,15 +80,15 @@ export function startMainWindowInputFocusRecovery(
dismissTransientUi();
return;
}
if (pendingExplicitShowRecovery) {
if (pendingExplicitFocusRecovery) {
recoverFocus();
}
};
documentRef.addEventListener("visibilitychange", onVisibilityChange);
const unsubscribeShown = bridge?.onWindowShown?.(() => {
pendingExplicitShowRecovery = true;
const unsubscribeFocusRequested = bridge?.onWindowFocusRequested?.(() => {
pendingExplicitFocusRecovery = true;
recoverFocus();
});
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
@@ -95,16 +96,16 @@ export function startMainWindowInputFocusRecovery(
});
return () => {
pendingExplicitShowRecovery = false;
pendingExplicitFocusRecovery = false;
cancelPendingFocusRecovery();
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
unsubscribeShown?.();
unsubscribeFocusRequested?.();
unsubscribeWillHide?.();
};
}
/**
* Recover OS/renderer input focus when the main process explicitly shows the
* Recover OS/renderer input focus when the main process explicitly foregrounds the
* main window (#760, #1714, #1722).
*/
export function useMainWindowInputFocusRecovery(
+110 -145
View File
@@ -11,7 +11,6 @@ createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
@@ -21,9 +20,11 @@ import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } fr
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
applyCloseSessionToSessions,
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
resolveActiveTabAfterCloseSession,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
@@ -390,85 +391,46 @@ export const useSessionState = ({
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
// Compute layout from the latest refs so dissolve/remove cannot desync from
// a stale workspaces/sessions render snapshot. Empty workspaces are already
// removed by closeSessionWorkspaceLayoutState — do NOT follow up with
// closeWorkspace(), which would race and delete remaining terminals that
// still briefly carry the old workspaceId.
const targetSession = sessionsRef.current.find((session) => session.id === sessionId);
const wsId = targetSession?.workspaceId;
const layoutResult = closeSessionWorkspaceLayoutState(
workspacesRef.current,
wsId,
sessionId,
);
const nextSessions = applyCloseSessionToSessions(
sessionsRef.current,
sessionId,
layoutResult,
);
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(layoutResult.workspaces);
setSessions(nextSessions);
setWorkspaces(prevWorkspaces => {
const {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
const currentActiveTabId = activeTabStore.getActiveTabId();
const getFallback = () => {
if (lastRemainingSessionId) return lastRemainingSessionId;
if (fallbackWorkspace) return fallbackWorkspace.id;
if (fallbackSolo) return fallbackSolo.id;
return 'vault';
};
if (dissolvedWorkspaceId && lastRemainingSessionId) {
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
dissolvedWorkspaceId,
[lastRemainingSessionId],
));
}
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
setActiveTabId(getFallback());
} else if (currentActiveTabId === sessionId) {
setActiveTabId(getFallback());
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
setActiveTabId(getFallback());
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
if (ws) {
const pruned = pruneWorkspaceNode(ws.root, sessionId);
if (pruned) {
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
// Dissolve: remove workspaceId from the remaining session
return prevSessions
.filter(s => s.id !== sessionId)
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
}
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
setTabOrder((prevTabOrder) => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
layoutResult.dissolvedWorkspaceId,
[layoutResult.lastRemainingSessionId!],
));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const nextActiveTabId = resolveActiveTabAfterCloseSession({
currentActiveTabId: activeTabStore.getActiveTabId(),
closedSessionId: sessionId,
workspaceId: wsId,
layoutResult,
remainingSessions: nextSessions,
});
if (nextActiveTabId) {
setActiveTabId(nextActiveTabId);
}
}, [setActiveTabId]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
@@ -630,20 +592,13 @@ export const useSessionState = ({
const newSessions: TerminalSession[] = targets.map((target) => {
if (target.kind === 'local') {
const sessionId = crypto.randomUUID();
return {
id: sessionId,
hostId: `local-${sessionId}`,
hostLabel: target.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
return createLocalTerminalSession(sessionId, {
shellType: target.shellType,
localShell: target.shell,
localShellArgs: target.shellArgs,
localShellName: target.shellName,
localShellIcon: target.shellIcon,
};
shell: target.shell,
shellArgs: target.shellArgs,
shellName: target.shellName,
shellIcon: target.shellIcon,
});
}
const host = target.host;
if (host.protocol === 'serial') {
@@ -708,23 +663,38 @@ export const useSessionState = ({
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId);
setSessions((prevSessions) => {
const base = prevSessions.find((s) => s.id === baseSessionId);
const joining = prevSessions.find((s) => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
setWorkspaces((prev) => addWorkspaceIfMissing(prev, newWorkspace));
// Collapse the two session tab slots into the workspace tab so later
// detach/close can replace `ws-*` with session ids again.
setTabOrder((prevTabOrder) => {
const withoutSessions = prevTabOrder.filter(
(id) => id !== baseSessionId && id !== joiningSessionId,
);
if (withoutSessions.includes(newWorkspace.id)) return withoutSessions;
const indexes = [baseSessionId, joiningSessionId]
.map((id) => prevTabOrder.indexOf(id))
.filter((index) => index >= 0);
const insertAt = indexes.length > 0 ? Math.min(...indexes) : withoutSessions.length;
const next = [...withoutSessions];
next.splice(Math.min(insertAt, next.length), 0, newWorkspace.id);
return next;
});
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
return prevSessions.map((s) => {
if (s.id === baseSessionId || s.id === joiningSessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
});
});
}, [setActiveTabId]);
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
@@ -821,20 +791,14 @@ export const useSessionState = ({
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
const newSessionId = crypto.randomUUID();
const localHostId = `local-${newSessionId}`;
const newSession: TerminalSession = {
id: newSessionId,
hostId: localHostId,
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
...createLocalTerminalSession(newSessionId, {
shellType: options?.shellType,
shell: options?.shell,
shellArgs: options?.shellArgs,
shellName: options?.shellName,
shellIcon: options?.shellIcon,
}),
workspaceId,
};
@@ -1152,40 +1116,41 @@ export const useSessionState = ({
additionalTabIds?: readonly string[];
},
) => {
setSessions(prevSessions => {
const result = detachSessionFromWorkspaceState({
sessions: prevSessions,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return prevSessions;
setWorkspaces(result.workspaces);
setTabOrder(prevTabOrder => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
...result.workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
tabInsertionTarget.position,
);
});
if (result.activeTabId) setActiveTabId(result.activeTabId);
return result.sessions;
// Detach from latest refs so continuous-render / memoized panes cannot
// act on a stale workspace snapshot and drop the terminal instead of
// restoring it as a standalone tab.
const result = detachSessionFromWorkspaceState({
sessions: sessionsRef.current,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return;
setWorkspaces(result.workspaces);
setSessions(result.sessions);
setTabOrder((prevTabOrder) => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
...result.workspaces.map((w) => w.id),
...logViews.map((lv) => lv.id),
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
tabInsertionTarget.position,
);
});
if (result.activeTabId) setActiveTabId(result.activeTabId);
}, [logViews, setActiveTabId]);
const reorderTabs = useCallback((
+238 -33
View File
@@ -2,6 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -39,8 +45,10 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
@@ -98,6 +106,7 @@ import {
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
DEFAULT_JMS_DEEP_LINK_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
@@ -130,6 +139,18 @@ import {
isTerminalSidePanelAutoOpenTab,
type TerminalSidePanelAutoOpenTab,
} from '../../domain/terminalSidePanelAutoOpen';
import {
parseWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
import {
hasPersistedAppearanceChanged,
resolveAppearanceSyncState,
type AppearanceRenderSnapshot,
type AppearanceSyncEvent,
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const enableSettingsSync = options.enableSettingsSync !== false;
@@ -322,6 +343,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
});
const [jmsDeepLinkEnabled, setJmsDeepLinkEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -337,6 +362,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (stored === null) return true;
return stored === 'true';
});
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
});
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
setHttpNetworkProxyState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: HttpNetworkProxySettings) => HttpNetworkProxySettings)(prev)
: nextValue;
// Preserve the previous object when values are unchanged so cross-window
// settings:changed IPC does not rebroadcast forever via useSystemSettingsEffects.
const next = normalizeHttpNetworkProxySettings(candidate);
return areHttpNetworkProxySettingsEqual(prev, next) ? prev : next;
});
}, []);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stored === null) return true; // Default to enabled
@@ -348,17 +388,34 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
const [windowOpacityRecord, setWindowOpacityRecord] = useState<WindowOpacityRecord>(() => {
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
if (stored === null) return DEFAULT_WINDOW_OPACITY;
return clampWindowOpacity(stored);
if (stored === null) return { opacity: DEFAULT_WINDOW_OPACITY, version: 0 };
return parseWindowOpacityRecord(stored);
});
const windowOpacity = windowOpacityRecord.opacity;
const windowOpacityMutationSourceRef = useRef<WindowOpacityMutationSource>('local');
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
setWindowOpacityState((prev) => {
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev)
? (nextValue as (prevState: number) => number)(prev.opacity)
: nextValue;
return clampWindowOpacity(candidate);
const nextOpacity = clampWindowOpacity(candidate);
if (nextOpacity === prev.opacity) return prev;
return { opacity: nextOpacity, version: prev.version + 1 };
});
}, []);
const applyIncomingWindowOpacity = useCallback((raw: unknown) => {
const incoming = parseWindowOpacityRecord(raw);
// Version gate so stale IPC/storage echoes cannot clobber a newer local
// drag revision (see #2018).
setWindowOpacityRecord((prev) => {
if (!shouldApplyWindowOpacityRecord(prev, incoming)) {
return prev;
}
windowOpacityMutationSourceRef.current = 'incoming';
return incoming;
});
}, []);
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
@@ -383,11 +440,31 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
const sshDeepLinkSetRequestIdRef = useRef(0);
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
const jmsDeepLinkSetRequestIdRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
const previousAppearanceRenderRef = useRef<AppearanceRenderSnapshot | null>(null);
// Latest appearance kept in a ref so sequential keyed IPC updates (one multi-field
// change -> multiple notifies) can compose without waiting for a React re-render.
const appearanceStateRef = useRef({
theme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
});
appearanceStateRef.current = {
theme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
};
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
@@ -476,6 +553,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
});
}, []);
const applyIncomingJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
jmsDeepLinkSetRequestIdRef.current += 1;
setJmsDeepLinkEnabledState((prev) => {
if (prev === enabled) return prev;
jmsDeepLinkMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
if (!enableSettingsSync) return;
@@ -504,29 +590,40 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
const storedLightId = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
const nextLightId = storedLightId && isValidUiThemeId('light', storedLightId) ? storedLightId : lightUiThemeId;
const storedDarkId = readStoredString(STORAGE_KEY_UI_THEME_DARK);
const nextDarkId = storedDarkId && isValidUiThemeId('dark', storedDarkId) ? storedDarkId : darkUiThemeId;
const storedAccentMode = readStoredString(STORAGE_KEY_ACCENT_MODE);
const nextAccentMode = storedAccentMode === 'theme' || storedAccentMode === 'custom' ? storedAccentMode : accentMode;
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
const current = appearanceStateRef.current;
const nextAppearance = resolveAppearanceSyncState(
current,
{
theme: readStoredString(STORAGE_KEY_THEME),
lightUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_LIGHT),
darkUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_DARK),
accentMode: readStoredString(STORAGE_KEY_ACCENT_MODE),
customAccent: readStoredString(STORAGE_KEY_COLOR),
},
incoming,
);
const {
theme: nextTheme,
lightUiThemeId: nextLightId,
darkUiThemeId: nextDarkId,
accentMode: nextAccentMode,
customAccent: nextAccent,
} = nextAppearance;
// Fix 2: Skip expensive DOM operations if nothing actually changed
if (
nextTheme === theme &&
nextLightId === lightUiThemeId &&
nextDarkId === darkUiThemeId &&
nextAccentMode === accentMode &&
nextAccent === customAccent
nextTheme === current.theme &&
nextLightId === current.lightUiThemeId &&
nextDarkId === current.darkUiThemeId &&
nextAccentMode === current.accentMode &&
nextAccent === current.customAccent
) {
return;
}
// Publish synchronously so a later keyed IPC in the same turn composes on top.
appearanceStateRef.current = nextAppearance;
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
@@ -538,7 +635,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
runThemeTransition(() => {
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
});
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, []);
const syncCustomCssFromStorage = useCallback(() => {
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
@@ -598,6 +695,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
@@ -645,11 +744,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// App-level HTTP(S) network proxy
setHttpNetworkProxyState(
normalizeHttpNetworkProxySettings(
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
),
);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const appearanceRender: AppearanceRenderSnapshot = {
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
};
// Capture previous snapshot before overwrite so we can notify only the
// appearance fields that actually changed on this render.
const previousAppearance = previousAppearanceRenderRef.current;
const persistedAppearanceChanged = previousAppearance === null
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
previousAppearanceRenderRef.current = appearanceRender;
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
@@ -659,6 +779,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
} else {
apply();
}
if (!persistedAppearanceChanged && persistMountedRef.current) return;
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -666,9 +787,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
// which re-reads ALL appearance values from localStorage.
notifySettingsChanged(STORAGE_KEY_THEME, theme);
// Emit a keyed IPC notification for each changed appearance field so the
// receiver can apply the payload value even if shared storage is still stale.
// resolveAppearanceSyncState only trusts the announced key's value; other
// fields fall back to storage, so every changed field must be announced.
if (!previousAppearance || previousAppearance.theme !== theme) {
notifySettingsChanged(STORAGE_KEY_THEME, theme);
}
if (!previousAppearance || previousAppearance.lightUiThemeId !== lightUiThemeId) {
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
}
if (!previousAppearance || previousAppearance.darkUiThemeId !== darkUiThemeId) {
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
}
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
}
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
@@ -724,13 +861,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowOpacity: applyIncomingWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -770,7 +909,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -779,8 +918,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -1087,14 +1226,76 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
}, [sshDeepLinkEnabled, notifySettingsChanged]);
useEffect(() => {
jmsDeepLinkEnabledRef.current = jmsDeepLinkEnabled;
}, [jmsDeepLinkEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = jmsDeepLinkSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getJmsDeepLinkEnabled) return;
void bridge.getJmsDeepLinkEnabled().then((enabled) => {
if (cancelled || typeof enabled !== 'boolean') return;
if (jmsDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, enabled);
}).catch(() => {
// The renderer can still use its cached setting when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
const previous = jmsDeepLinkEnabledRef.current;
const requestId = jmsDeepLinkSetRequestIdRef.current + 1;
jmsDeepLinkSetRequestIdRef.current = requestId;
jmsDeepLinkMutationSourceRef.current = 'local';
setJmsDeepLinkEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setJmsDeepLinkEnabled) return;
void bridge.setJmsDeepLinkEnabled(enabled).then((result) => {
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
const success = typeof result === 'object' ? result.success : result;
if (success !== false) return;
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
? result.enabled
: previous;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, finalEnabled);
}).catch(() => {
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
if (jmsDeepLinkMutationSourceRef.current === 'incoming') {
jmsDeepLinkMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
enabled: enableSystemEffects,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
@@ -1278,11 +1479,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshDebugLogsEnabled,
sshDeepLinkEnabled,
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
httpNetworkProxy,
setHttpNetworkProxy,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
@@ -1305,7 +1510,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
]),
};
};
+7
View File
@@ -168,6 +168,11 @@ export const useTerminalBackend = () => {
return bridge?.onWindowFullScreenChanged?.(cb);
}, []);
const onWindowShown = useCallback((cb: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowShown?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
@@ -370,6 +375,7 @@ export const useTerminalBackend = () => {
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onWindowShown,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
@@ -422,6 +428,7 @@ export const useTerminalBackend = () => {
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onWindowShown,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
+1 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import type { Host, TerminalTheme } from '../../domain/models';
import type { TerminalTheme } from '../../domain/models';
import {
idleThemeUserIntent,
pickingThemeUserIntent,
@@ -113,7 +113,3 @@ export function useTerminalAppearanceInjection(
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
}
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
return { host, isEphemeral };
}
+4
View File
@@ -23,6 +23,7 @@ export interface UseVaultAgentBridgeInput {
updateNotes: (notes: VaultNote[]) => void;
startTunnel: VaultAgentApiDeps['startTunnel'];
stopTunnel: VaultAgentApiDeps['stopTunnel'];
openHost?: VaultAgentApiDeps['openHost'];
}
type VaultAgentSnapshot = {
@@ -107,6 +108,9 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
},
startTunnel: current.startTunnel,
stopTunnel: current.stopTunnel,
openHost: current.openHost
? (hostId) => current.openHost!(hostId)
: undefined,
});
});
return setupVaultAgentBridge();
+199
View File
@@ -0,0 +1,199 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
shouldBroadcastWindowOpacityChange,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync.ts';
/**
* Minimal model of the settings ↔ main opacity sync loop that caused #2018.
* Models both IPC rebroadcast and stale localStorage overwrites.
*/
function simulateOpacityDrag(options: {
shouldBroadcast: (
source: WindowOpacityMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: WindowOpacityMutationSource };
shouldApply: (current: WindowOpacityRecord, incoming: WindowOpacityRecord) => boolean;
versioned: boolean;
}): { settingsValues: number[]; mainValues: number[]; storageWrites: string[] } {
let settings: WindowOpacityRecord = { opacity: 1, version: 0 };
let main: WindowOpacityRecord = { opacity: 1, version: 0 };
let settingsSource: WindowOpacityMutationSource = 'local';
let mainSource: WindowOpacityMutationSource = 'local';
let storage = serializeWindowOpacityRecord(settings);
const settingsValues: number[] = [];
const mainValues: number[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: WindowOpacityRecord }> = [];
const writeStorage = (record: WindowOpacityRecord) => {
const next = options.versioned
? serializeWindowOpacityRecord(record)
: String(record.opacity);
if (next === storage) return;
storage = next;
storageWrites.push(next);
// Cross-window StorageEvent: deliver to the other window.
};
const applyLocal = (window: 'settings' | 'main', opacity: number) => {
const bump = (prev: WindowOpacityRecord): WindowOpacityRecord => (
options.versioned
? { opacity, version: prev.version + 1 }
: { opacity, version: 0 }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings);
settingsValues.push(settings.opacity);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main);
mainValues.push(main.opacity);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: WindowOpacityRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.opacity);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.opacity);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
// Replay storage writes onto the peer after each local burst, matching
// Electron's cross-window StorageEvent delivery.
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = options.versioned
? parseWindowOpacityRecord(JSON.parse(storage))
: parseWindowOpacityRecord(storage);
applyIncoming(window, record);
};
applyLocal('settings', 0.5);
deliverStorageTo('main');
applyLocal('settings', 0.65);
// Delayed IPC for the older 0.5 update arrives after 0.65 was already local.
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseWindowOpacityRecord accepts legacy plain numbers and versioned JSON', () => {
assert.deepEqual(parseWindowOpacityRecord('0.85'), { opacity: 0.85, version: 0 });
assert.deepEqual(parseWindowOpacityRecord(0.7), { opacity: 0.7, version: 0 });
assert.deepEqual(
parseWindowOpacityRecord({ opacity: 0.5, version: 3 }),
{ opacity: 0.5, version: 3 },
);
assert.deepEqual(
parseWindowOpacityRecord('{"opacity":0.55,"version":9}'),
{ opacity: 0.55, version: 9 },
);
assert.equal(parseWindowOpacityRecord('bad').opacity, 1);
});
test('shouldApplyWindowOpacityRecord ignores stale revisions', () => {
const current = { opacity: 0.65, version: 2 };
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.5, version: 1 }), false);
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.7, version: 3 }), true);
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.65, version: 2 }), false);
});
test('shouldBroadcastWindowOpacityChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastWindowOpacityChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastWindowOpacityChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastWindowOpacityChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast opacity sync oscillates during a fast drag', () => {
const alwaysBroadcast = (
_source: WindowOpacityMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateOpacityDrag({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has(0.5) && unique.has(0.65) && settingsValues.length > 2,
`expected oscillation between 0.5 and 0.65, got ${settingsValues.join(',')}`,
);
});
test('versioned opacity sync ignores stale peer echoes during a fast drag', () => {
const { settingsValues, mainValues } = simulateOpacityDrag({
shouldBroadcast: shouldBroadcastWindowOpacityChange,
shouldApply: shouldApplyWindowOpacityRecord,
versioned: true,
});
assert.deepEqual(settingsValues, [0.5, 0.65]);
assert.ok(mainValues.includes(0.65));
assert.equal(mainValues.includes(0.5) && mainValues[mainValues.length - 1] === 0.5, false);
});
test('serializeWindowOpacityRecord round-trips through parse', () => {
const raw = serializeWindowOpacityRecord({ opacity: 0.55, version: 9 });
assert.deepEqual(parseWindowOpacityRecord(JSON.parse(raw)), { opacity: 0.55, version: 9 });
});
+88
View File
@@ -0,0 +1,88 @@
import {
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
} from './settingsStateDefaults';
export { DEFAULT_WINDOW_OPACITY, clampWindowOpacity };
export type WindowOpacityMutationSource = 'local' | 'incoming';
export type WindowOpacityRecord = {
opacity: number;
version: number;
};
/**
* Parse persisted / IPC window-opacity payloads.
* Accepts legacy plain numbers ("0.85") and versioned records.
*/
export function parseWindowOpacityRecord(raw: unknown): WindowOpacityRecord {
if (typeof raw === 'number' || typeof raw === 'string') {
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
// Prefer JSON object payloads written by serializeWindowOpacityRecord.
if (typeof trimmed === 'string' && trimmed.startsWith('{')) {
try {
return parseWindowOpacityRecord(JSON.parse(trimmed));
} catch {
// fall through to Number()
}
}
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber)) {
return { opacity: clampWindowOpacity(asNumber), version: 0 };
}
}
if (raw && typeof raw === 'object') {
const record = raw as { opacity?: unknown; version?: unknown };
const opacity = clampWindowOpacity(record.opacity);
const version = Number(record.version);
return {
opacity,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
};
}
return { opacity: DEFAULT_WINDOW_OPACITY, version: 0 };
}
export function serializeWindowOpacityRecord(record: WindowOpacityRecord): string {
return JSON.stringify({
opacity: clampWindowOpacity(record.opacity),
version: Math.max(0, Math.floor(record.version) || 0),
});
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
*/
export function shouldApplyWindowOpacityRecord(
current: WindowOpacityRecord,
incoming: WindowOpacityRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
// Same version: only apply when the opacity itself differs and both are
// legacy/unversioned (version 0), so first-load plain strings still sync.
if (incoming.version === 0 && current.version === 0) {
return incoming.opacity !== current.opacity;
}
return false;
}
/**
* Decide whether a window-opacity state change should be rebroadcast to peer
* windows. Incoming IPC/storage updates must not notify again — otherwise a
* fast slider drag in the settings window ping-pongs with the main window and
* the controlled range input oscillates (see #2018).
*/
export function shouldBroadcastWindowOpacityChange(
mutationSource: WindowOpacityMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: WindowOpacityMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as storageKeys from "../../infrastructure/config/storageKeys.ts";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "./ymodemFileMemory.ts";
type TestStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const createStore = (initial: Record<string, string> = {}): {
store: TestStore;
values: Map<string, string>;
} => {
const values = new Map(Object.entries(initial));
return {
store: {
readString: (key: string) => values.get(key) ?? null,
writeString: (key: string, value: string) => {
values.set(key, value);
return true;
},
},
values,
};
};
test("uses the remembered YMODEM send directory as the file picker default path", () => {
assert.equal(
storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR,
"netcatty_terminal_ymodem_send_dir_v1",
);
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases");
});
test("preserves valid remembered directory whitespace", () => {
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases ");
});
test("remembers the directory containing a selected YMODEM send file", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases/device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware/releases",
);
});
test("keeps POSIX backslashes as part of the selected file name", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware",
);
});
test("remembers Windows-style YMODEM send directories", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("C:\\Users\\catty\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"C:\\Users\\catty",
);
});
test("ignores blank remembered paths and selected file names without a parent directory", () => {
const { store, values } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: " ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), undefined);
assert.equal(rememberYmodemSendFilePath("device.bin", store), false);
assert.equal(values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR), " ");
});
+38
View File
@@ -0,0 +1,38 @@
import { STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
type YmodemFileMemoryStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const getParentDirectory = (filePath: string): string | null => {
const lastForwardSlash = filePath.lastIndexOf("/");
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
const lastSeparator = isWindowsPath
? Math.max(lastForwardSlash, filePath.lastIndexOf("\\"))
: lastForwardSlash;
if (lastSeparator < 0) return null;
if (lastSeparator === 0) return filePath.slice(0, 1);
if (lastSeparator === 2 && filePath[1] === ":") return filePath.slice(0, 3);
return filePath.slice(0, lastSeparator);
};
export const getRememberedYmodemSendDefaultPath = (
store: YmodemFileMemoryStore = localStorageAdapter,
): string | undefined => {
const rememberedPath = store.readString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR);
if (!rememberedPath || rememberedPath.trim() === "") return undefined;
return rememberedPath;
};
export const rememberYmodemSendFilePath = (
filePath: string,
store: YmodemFileMemoryStore = localStorageAdapter,
): boolean => {
const directory = getParentDirectory(filePath);
if (!directory) return false;
return store.writeString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR, directory);
};
+37
View File
@@ -683,6 +683,8 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
@@ -697,6 +699,8 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
@@ -983,6 +987,39 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload writes incoming Shift+Enter terminal settings", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000 }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
},
},
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 5000);
assert.equal(parsed.shiftEnterNewlineEnabled, false);
assert.equal(parsed.shiftEnterNewlineText, " \\\\\\n");
});
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
+2 -1
View File
@@ -200,7 +200,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
-16
View File
@@ -108,22 +108,6 @@ export function modelPresetsContainId(presets: AgentModelPreset[], modelId: stri
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
// reduce to their basename rather than staying as the full path.
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return sdkBackend === 'claude'
+6
View File
@@ -218,6 +218,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [s3SessionToken, setS3SessionToken] = useState('');
const [s3Prefix, setS3Prefix] = useState('');
const [s3ForcePathStyle, setS3ForcePathStyle] = useState(true);
const [s3AllowInsecure, setS3AllowInsecure] = useState(false);
const [showS3Secret, setShowS3Secret] = useState(false);
const [s3Error, setS3Error] = useState<string | null>(null);
const [s3ErrorDetail, setS3ErrorDetail] = useState<string | null>(null);
@@ -417,6 +418,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
setS3SessionToken(config?.sessionToken || '');
setS3Prefix(config?.prefix || '');
setS3ForcePathStyle(config?.forcePathStyle ?? true);
setS3AllowInsecure(config?.allowInsecure || false);
setShowS3Secret(false);
setS3Error(null);
setS3ErrorDetail(null);
@@ -489,6 +491,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
sessionToken: s3SessionToken.trim() ? s3SessionToken.trim() : undefined,
prefix: s3Prefix.trim() ? s3Prefix.trim() : undefined,
forcePathStyle: s3ForcePathStyle,
allowInsecure: s3AllowInsecure ? true : undefined,
};
setIsSavingS3(true);
@@ -508,6 +511,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
region: s3Region.trim(),
bucket: s3Bucket.trim(),
forcePathStyle: s3ForcePathStyle,
allowInsecure: s3AllowInsecure,
}),
);
toast.error(message, t('cloudSync.connect.s3.failedTitle'));
@@ -808,6 +812,8 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
setS3Prefix={setS3Prefix}
s3ForcePathStyle={s3ForcePathStyle}
setS3ForcePathStyle={setS3ForcePathStyle}
s3AllowInsecure={s3AllowInsecure}
setS3AllowInsecure={setS3AllowInsecure}
showS3Secret={showS3Secret}
setShowS3Secret={setShowS3Secret}
s3Error={s3Error}
+96 -2
View File
@@ -1,8 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { GroupConfig, Host } from "../types.ts";
import { getHostTreeDisplayDetails } from "./HostTreeView.tsx";
import type { GroupConfig, GroupNode, Host } from "../types.ts";
import { getHostTreeDisplayDetails, HostTreeView } from "./HostTreeView.tsx";
const baseHost: Host = {
id: "host-1",
@@ -16,6 +18,22 @@ const baseHost: Host = {
createdAt: 1,
};
const installLocalStorageMock = () => {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
},
});
};
test("HostTreeView display details include inherited telnet defaults", () => {
const host: Host = {
...baseHost,
@@ -56,3 +74,79 @@ test("HostTreeView display details keep explicit cleared telnet username", () =>
port: 2325,
});
});
test("HostTreeView renders the host edit action beside the host label", () => {
installLocalStorageMock();
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[]}
hosts={[{
...baseHost,
notes: "Maintenance notes",
tags: ["edge"],
}]}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
const labelIndex = markup.indexOf("Router");
const editButtonIndex = markup.indexOf('data-host-tree-host-edit-button="host-1"');
const notesIndex = markup.indexOf('aria-label="Host notes"', labelIndex);
const protocolIndex = markup.indexOf("TELNET", labelIndex);
assert.ok(labelIndex >= 0);
assert.ok(editButtonIndex > labelIndex);
assert.ok(notesIndex > editButtonIndex);
assert.ok(protocolIndex > notesIndex);
});
test("HostTreeView renders the group edit action beside the group label", () => {
installLocalStorageMock();
const groupNode: GroupNode = {
name: "Production",
path: "production",
children: {},
hosts: [],
totalHostCount: 2,
};
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[groupNode]}
hosts={[]}
expandedPaths={new Set<string>()}
onTogglePath={() => undefined}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
const labelIndex = markup.indexOf("Production");
const editButtonIndex = markup.indexOf('data-host-tree-group-edit-button="production"');
const countIndex = markup.indexOf(">2<", editButtonIndex);
assert.ok(labelIndex >= 0);
assert.ok(editButtonIndex > labelIndex);
assert.ok(countIndex > editButtonIndex);
});
+18 -14
View File
@@ -272,15 +272,17 @@ const TreeNode: React.FC<TreeNodeProps> = ({
Managed
</span>
)}
actions={(
labelActions={(
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
aria-label={`Edit ${node.name}`}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
<Edit2 size={12} />
</button>
)}
/>
@@ -463,6 +465,17 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-1.5 truncate font-medium leading-4">
<span className="truncate">{host.label}</span>
<button
aria-label={`Edit ${host.label}`}
data-host-tree-host-edit-button={host.id}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors opacity-0 hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={12} />
</button>
<HostNotesIndicator notes={host.notes} />
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
@@ -470,7 +483,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
</div>
</div>
)}
actions={(
actions={(displayProtocol && displayProtocol !== 'ssh') || tags.length > 0 ? (
<div className="ml-2 flex shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] leading-none text-primary">
@@ -483,17 +496,8 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
{tags.length > 2 && '...'}
</span>
)}
<button
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-secondary/80"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={12} />
</button>
</div>
)}
) : undefined}
/>
</ContextMenuTrigger>
<HostTreeHostContextMenuContent
+56 -27
View File
@@ -51,6 +51,7 @@ import {
vaultHeaderIconButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import utilities and components from keychain module
@@ -115,6 +116,11 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
const { generateKeyPair, execCommand } = useKeychainBackend();
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "key" | "identity";
id: string;
name: string;
} | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_KEYS_VIEW_MODE,
"grid",
@@ -468,24 +474,49 @@ echo $3 >> "$FILE"`);
// Handle delete
const handleDelete = useCallback(
async (id: string) => {
onDelete(id);
if (panel.type === "view" && panel.key.id === id) {
closePanel();
}
(id: string) => {
const key = keys.find((item) => item.id === id);
setDeleteTarget({
type: "key",
id,
name: key?.label || t("keychain.panel.keyDetails"),
});
},
[onDelete, panel, closePanel],
[keys, t],
);
// Handle delete identity
const _handleDeleteIdentity = useCallback(
(id: string) => {
onDeleteIdentity?.(id);
if (panel.type === "identity" && panel.identity?.id === id) {
closePanel();
}
const identity = identities.find((item) => item.id === id);
setDeleteTarget({
type: "identity",
id,
name: identity?.label || t("keychain.panel.editIdentity"),
});
},
[onDeleteIdentity, panel, closePanel],
[identities, t],
);
const confirmDeleteTarget = useCallback(
() => {
if (!deleteTarget) return;
if (deleteTarget.type === "key") {
onDelete(deleteTarget.id);
if (panel.type === "view" && panel.key.id === deleteTarget.id) {
closePanel();
}
} else {
onDeleteIdentity?.(deleteTarget.id);
if (panel.type === "identity" && panel.identity?.id === deleteTarget.id) {
closePanel();
}
}
setDeleteTarget(null);
},
[closePanel, deleteTarget, onDelete, onDeleteIdentity, panel],
);
// Get icon for key source
@@ -837,15 +868,7 @@ echo $3 >> "$FILE"`);
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive"
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: identity.label || "",
}),
);
if (!ok) return;
_handleDeleteIdentity(identity.id);
}}
onClick={() => _handleDeleteIdentity(identity.id)}
>
<Trash2 className="mr-2 h-4 w-4" />{" "}
{t("action.delete")}
@@ -876,13 +899,7 @@ echo $3 >> "$FILE"`);
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: panel.identity?.label || "",
}),
);
if (!ok || !panel.identity) return;
_handleDeleteIdentity(panel.identity.id);
if (panel.identity) _handleDeleteIdentity(panel.identity.id);
}}
>
{t("common.delete")}
@@ -1031,6 +1048,18 @@ echo $3 >> "$FILE"`);
)}
</AsidePanel>
)}
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t("vault.deleteConfirm.title", {
name: deleteTarget?.name ?? "",
})}
description={t("vault.deleteConfirm.desc")}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
</div>
);
};
+29 -2
View File
@@ -45,6 +45,7 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
@@ -294,6 +295,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [isScanning, setIsScanning] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
"grid",
@@ -471,9 +473,23 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
// Memoized handlers to prevent re-renders
const handleDelete = useCallback(
(id: string) => {
onDelete(id);
setDeleteTargetId(id);
},
[onDelete],
[],
);
const deleteTarget = useMemo(
() => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null,
[deleteTargetId, knownHosts],
);
const confirmDelete = useCallback(
() => {
if (!deleteTargetId) return;
onDelete(deleteTargetId);
setDeleteTargetId(null);
},
[deleteTargetId, onDelete],
);
const handleConvertToHost = useCallback(
@@ -670,6 +686,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
)}
</div>
</ScrollArea>
<VaultDeleteConfirmDialog
open={Boolean(deleteTargetId)}
title={t("vault.deleteConfirm.title", {
name: deleteTarget?.hostname ?? "",
})}
description={t("vault.deleteConfirm.desc")}
onOpenChange={(open) => {
if (!open) setDeleteTargetId(null);
}}
onConfirm={confirmDelete}
/>
</div>
);
};
+8
View File
@@ -0,0 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_REMEMBER_PASSPHRASE } from "./PassphraseModal.tsx";
test("passphrase remember checkbox defaults to unchecked for security", () => {
assert.equal(DEFAULT_REMEMBER_PASSPHRASE, false);
});
+5 -2
View File
@@ -23,6 +23,9 @@ export interface PassphraseRequest {
hostname?: string;
}
/** Opt-in only: do not default to remembering key passphrases (#2024). */
export const DEFAULT_REMEMBER_PASSPHRASE = false;
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
@@ -40,7 +43,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberPassphrase, setRememberPassphrase] = useState(true);
const [rememberPassphrase, setRememberPassphrase] = useState(DEFAULT_REMEMBER_PASSPHRASE);
// Reset state when request changes
useEffect(() => {
@@ -48,7 +51,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
setRememberPassphrase(true);
setRememberPassphrase(DEFAULT_REMEMBER_PASSPHRASE);
}
}, [request]);
+30 -63
View File
@@ -1,5 +1,4 @@
import {
AlertTriangle,
Check,
ChevronDown,
Globe,
@@ -32,14 +31,6 @@ import {
AsidePanelFooter,
} from "./ui/aside-panel";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { SortDropdown } from "./ui/sort-dropdown";
import { toast } from "./ui/toast";
@@ -50,6 +41,7 @@ import {
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import components and utilities from port-forwarding module
@@ -436,33 +428,24 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
setSelectedRuleId(null);
}, [setSelectedRuleId]);
// Handle delete with confirmation for active tunnels
// Handle delete with confirmation
const handleDeleteRule = useCallback(
(rule: PortForwardingRule) => {
// If tunnel is active or connecting, show confirmation dialog
if (rule.status === "active" || rule.status === "connecting") {
setRuleToDelete(rule);
setShowDeleteConfirm(true);
} else {
// If inactive, delete directly
if (editingRule?.id === rule.id) {
closeEditPanel();
}
deleteRule(rule.id);
}
setRuleToDelete(rule);
setShowDeleteConfirm(true);
},
[editingRule, deleteRule, closeEditPanel],
[],
);
// Confirm delete of active tunnel: stop first, then delete
const confirmDeleteActiveRule = useCallback(async () => {
// Confirm delete; active tunnels are stopped first.
const confirmDeleteRule = useCallback(async () => {
if (!ruleToDelete) return;
setIsDeleting(true);
try {
// Stop the tunnel first
await stopTunnel(ruleToDelete.id);
// Then delete the rule
if (ruleToDelete.status === "active" || ruleToDelete.status === "connecting") {
await stopTunnel(ruleToDelete.id);
}
if (editingRule?.id === ruleToDelete.id) {
closeEditPanel();
}
@@ -474,6 +457,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
const deleteTargetIsActive = ruleToDelete?.status === "active" || ruleToDelete?.status === "connecting";
// Handle wizard navigation
// Flow for local: type -> local-config -> destination -> host-selection
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
@@ -923,44 +908,26 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
/>
)}
{/* Delete Active Tunnel Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
if (!isDeleting) {
<VaultDeleteConfirmDialog
open={showDeleteConfirm}
title={t("vault.deleteConfirm.title", {
name: ruleToDelete?.label ?? "",
})}
description={
deleteTargetIsActive
? t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })
: t("vault.deleteConfirm.portForwardingDesc")
}
confirmLabel={deleteTargetIsActive ? t("pf.deleteActive.confirm") : undefined}
disabled={isDeleting}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
if (!open) setRuleToDelete(null);
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t("pf.deleteActive.title")}
</DialogTitle>
<DialogDescription>
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setRuleToDelete(null);
}}
disabled={isDeleting}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteActiveRule}
disabled={isDeleting}
>
{t("pf.deleteActive.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}}
onConfirm={() => {
void confirmDeleteRule();
}}
/>
</div>
);
};
+3 -2
View File
@@ -188,7 +188,8 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
return true;
case "auth":
if (authMethod === "password") {
return password.trim().length > 0;
// Whitespace-only passwords are valid SSH secrets (issue #2036).
return password.length > 0;
}
return !!selectedKeyId;
}
@@ -434,7 +435,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
className="pr-10"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && password.trim()) {
if (e.key === "Enter" && password.length > 0) {
handleConnect();
}
}}
+16 -7
View File
@@ -9,10 +9,10 @@ import {
} from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import { matchesSearchQuery } from "../lib/searchMatcher";
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
type QuickSwitcherItem = {
type: "host" | "tab" | "workspace" | "action" | "shell";
@@ -73,6 +73,7 @@ interface QuickSwitcherProps {
onCreateWorkspace?: () => void;
keyBindings?: KeyBinding[];
showSftpTab: boolean;
terminalSettings?: Pick<TerminalSettings, "localShell" | "localShellArgs">;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -89,19 +90,27 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onCreateWorkspace,
keyBindings,
showSftpTab,
terminalSettings,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const quickSwitcherShells = useMemo(() => (
buildQuickSwitcherShells(
discoveredShells,
terminalSettings?.localShell ?? "",
terminalSettings?.localShellArgs,
)
), [discoveredShells, terminalSettings?.localShell, terminalSettings?.localShellArgs]);
const filteredShells = useMemo(() => {
const list = !query.trim()
? discoveredShells
: discoveredShells.filter(
(s) => matchesSearchQuery(query, s.name, s.id)
? quickSwitcherShells
: quickSwitcherShells.filter(
(s) => matchesSearchQuery(query, s.name, s.id, s.command)
);
// Default shell first
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
}, [discoveredShells, query]);
}, [quickSwitcherShells, query]);
// Get hotkey display strings
const getHotkeyLabel = useCallback((actionId: string) => {
@@ -270,7 +279,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}
break;
case "shell": {
const shell = discoveredShells.find(s => s.id === item.id);
const shell = quickSwitcherShells.find(s => s.id === item.id);
if (shell && onCreateLocalTerminal) {
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
onClose();
+16
View File
@@ -18,6 +18,9 @@ import { SettingsTabContent } from "./settings/settings-ui";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { LazyLoadBoundary } from "./ui/lazy-load-boundary";
import { ExternalMcpApprovalsHost } from "./ai/ExternalMcpApprovalsHost";
import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister";
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
@@ -306,6 +309,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
notifyRendererReady();
}, [notifyRendererReady]);
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
useExternalMcpGrantPersister();
useEffect(() => {
const unsubscribe = onWindowCommandCloseRequested(() => {
void closeSettingsWindow();
@@ -327,6 +336,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
}, [closeSettingsWindow]);
return (
<>
<div className="settings-window h-screen flex flex-col bg-background text-foreground font-sans">
<div className="shrink-0 border-b border-border app-drag">
<div className="flex items-center justify-between px-4 pt-3">
@@ -548,6 +558,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
sshDeepLinkEnabled={settings.sshDeepLinkEnabled}
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
restorePreviousSession={settings.restorePreviousSession}
setRestorePreviousSession={settings.setRestorePreviousSession}
restoreTerminalCwd={settings.restoreTerminalCwd}
@@ -556,6 +568,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setToggleWindowHotkey={settings.setToggleWindowHotkey}
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
httpNetworkProxy={settings.httpNetworkProxy}
setHttpNetworkProxy={settings.setHttpNetworkProxy}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
globalHotkeyEnabled={settings.globalHotkeyEnabled}
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
@@ -572,6 +586,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</div>
</Tabs>
</div>
<ExternalMcpApprovalsHost />
</>
);
};
+79 -19
View File
@@ -43,6 +43,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
import {
mergeLatestFollowTerminalCwdHostSetting,
resolveHostFollowTerminalCwd,
shouldApplyFollowTerminalCwdSyncResult,
shouldClearBlockedFollowOnReach,
shouldFollowTerminalCwdNavigate,
type SftpFollowTerminalCwdBlock,
@@ -52,10 +53,13 @@ import {
shouldResetSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
} from "./sftp/sftpSidePanelAutoConnect";
import { listSftpConnectedHosts, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import type { TerminalSession } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
writableHosts?: Host[];
sessions?: TerminalSession[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
@@ -99,6 +103,7 @@ interface SftpSidePanelProps {
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
sessions = [],
keys,
identities,
knownHosts = [],
@@ -132,6 +137,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
const connectedHosts = useMemo(() => {
const hostsById = new Map<string, Host>(
hosts.map((host) => [host.id, host]),
);
return listSftpConnectedHosts(sessions, hostsById);
}, [hosts, sessions]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -479,6 +490,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpSidePanelInteractiveBody
hosts={hosts}
hostWriteSource={hostWriteSource}
connectedHosts={connectedHosts}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
@@ -521,6 +533,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
type SftpSidePanelInteractiveBodyProps = {
hosts: Host[];
hostWriteSource: Host[];
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
updateHosts: (hosts: Host[]) => void;
sftp: ReturnType<typeof useSftpState>;
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
@@ -560,6 +573,7 @@ type SftpSidePanelInteractiveBodyProps = {
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
hosts,
hostWriteSource,
connectedHosts,
updateHosts,
sftp,
sftpRef,
@@ -778,25 +792,32 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const followSyncGenerationRef = useRef(0);
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
const activeTerminalCwdRef = useRef(activeTerminalCwd);
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionIdRef = useRef(connectionId);
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
activeTerminalCwdRef.current = activeTerminalCwd;
connectionIdRef.current = connectionId;
const invalidateInFlightFollowSync = useCallback(() => {
followSyncGenerationRef.current += 1;
blockedFollowRef.current = null;
handledFollowRef.current = null;
}, []);
useEffect(() => {
blockedFollowRef.current = null;
invalidateInFlightFollowSync();
}, [
activeTerminalCwd,
followTerminalCwdHost?.id,
connectionId,
invalidateInFlightFollowSync,
]);
useEffect(() => {
@@ -805,15 +826,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
useEffect(() => {
const blockedFollow = blockedFollowRef.current;
if (
shouldClearBlockedFollowOnReach(
blockedFollowRef.current,
blockedFollow,
connectionId,
connectionPath,
sftp.leftPane.loading,
)
) {
blockedFollowRef.current = null;
handledFollowRef.current = blockedFollow;
}
}, [connectionId, connectionPath, sftp.leftPane.loading]);
@@ -824,6 +847,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
if (navigateResult === "reached") {
blockedFollowRef.current = null;
const connection = sftpRef.current.leftPane.connection;
if (connection?.id) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
}
}
}, [onGetTerminalCwd, sftpRef]);
@@ -834,16 +861,18 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const syncGeneration = followSyncGenerationRef.current;
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
let terminalCwd = activeTerminalCwd;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
}
if (!terminalCwd) return;
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -857,16 +886,43 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
blockedFollow: blockedFollowRef.current,
handledFollow: handledFollowRef.current,
})) {
if (
connection?.id
&& !connection.isLocal
&& connection.status === "connected"
&& connection.currentPath === terminalCwd
) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
}
return;
}
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd);
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
const expectedConnectionId = connection?.id ?? null;
const shouldApplyCurrentFollowSync = () => (
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
expectedConnectionId,
liveConnectionId: connectionIdRef.current,
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
expectedTerminalCwd: terminalCwd,
liveTerminalCwd: activeTerminalCwdRef.current,
requireLiveTerminalCwd: usesLiveTerminalCwd,
})
);
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
shouldApply: shouldApplyCurrentFollowSync,
});
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -877,8 +933,11 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
if (navigateResult === "failed" && currentConnection.id) {
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "superseded" && currentConnection.id) {
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "reached") {
blockedFollowRef.current = null;
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
}
}, [
activeTerminalCwd,
@@ -892,9 +951,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const handleToggleFollowTerminalCwd = useCallback(() => {
const nextEnabled = !effectiveFollowTerminalCwd;
if (!nextEnabled) {
invalidateInFlightFollowSync();
}
invalidateInFlightFollowSync();
if (followTerminalCwdHost?.id) {
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
}
@@ -910,7 +967,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
effectiveFollowTerminalCwd,
hasActiveWork,
isVisible,
sftp.leftPane.connection?.currentPath,
connectionId,
sftp.leftPane.connection?.status,
sftp.leftPane.connection?.isLocal,
syncFollowToTerminalCwd,
@@ -1007,6 +1064,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
return (
<SftpContextProvider
hosts={hosts}
connectedHosts={connectedHosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
@@ -1094,6 +1152,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
{renderOverlays && (
<SftpOverlays
hosts={hosts}
connectedHosts={connectedHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
@@ -1142,6 +1201,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.writableHosts === next.writableHosts &&
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
+75
View File
@@ -51,3 +51,78 @@ test("SftpView re-renders when host-key verification setting changes", async ()
false,
);
});
test("SftpView ignores session title-only updates for memoization", async () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
});
const { sftpViewAreEqual } = await import("./SftpView.tsx");
const baseProps = {
hosts: [],
sessions: [{
id: "s1",
hostId: "h1",
hostLabel: "Host",
username: "alice",
hostname: "h1.example.test",
protocol: "ssh",
status: "connected",
dynamicTitle: "old",
}],
keys: [],
identities: [],
knownHosts: [],
groupConfigs: [],
proxyProfiles: [],
updateHosts: () => {},
onAddKnownHost: () => {},
sftpDefaultViewMode: "list",
sftpDoubleClickBehavior: "open",
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
hotkeyScheme: {},
keyBindings: [],
editorWordWrap: false,
setEditorWordWrap: () => {},
terminalSettings: {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
},
};
assert.equal(
sftpViewAreEqual(
baseProps as never,
{
...baseProps,
sessions: [{
...baseProps.sessions[0],
dynamicTitle: "new title from OSC",
}],
} as never,
),
true,
);
assert.equal(
sftpViewAreEqual(
baseProps as never,
{
...baseProps,
sessions: [{
...baseProps.sessions[0],
status: "connecting",
}],
} as never,
),
false,
);
});
+22 -3
View File
@@ -20,7 +20,8 @@ import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
import { listSftpConnectedHosts, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
@@ -52,6 +53,9 @@ import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hoo
// Main SftpView component
interface SftpViewProps {
hosts: Host[];
/** Vault-persisted hosts only; used for writes so ephemeral deep-link hosts stay out of vault. */
writableHosts?: Host[];
sessions?: TerminalSession[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
@@ -73,6 +77,8 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
writableHosts,
sessions = [],
keys,
identities,
knownHosts = [],
@@ -129,6 +135,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
});
}, [hosts, groupConfigs, proxyProfiles]);
const hostWriteSource = writableHosts ?? hosts;
const connectedHosts = useMemo(() => {
const hostsById = new Map<string, Host>(
effectiveHosts.map((host) => [host.id, host]),
);
return listSftpConnectedHosts(sessions, hostsById);
}, [effectiveHosts, sessions]);
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
// Get backend helpers for file downloads and local filesystem writes.
@@ -429,7 +444,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
return (
<SftpContextProvider
hosts={effectiveHosts}
writableHosts={hosts}
connectedHosts={connectedHosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -439,7 +455,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<div
ref={rootRef}
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
"absolute inset-0 min-h-0 flex flex-col bg-background",
isActive ? "z-20" : "",
)}
style={containerStyle}
@@ -571,6 +587,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpOverlays
hosts={effectiveHosts}
connectedHosts={connectedHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
@@ -616,6 +633,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.writableHosts === next.writableHosts &&
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
+64 -4
View File
@@ -43,6 +43,7 @@ import {
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
clearVaultDropIndicator as clearSnippetDropIndicator,
getVaultDropIntent as getPackageDropIntent,
@@ -498,6 +499,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{
type: 'snippet' | 'package';
id: string;
name: string;
} | null>(null);
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
@@ -1189,7 +1195,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
setIsPackageDialogOpen(false);
};
const deletePackage = (path: string) => {
const performDeletePackage = (path: string) => {
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
const updatedSnippets = snippets.map((s) => {
@@ -1209,6 +1215,44 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}
};
const requestDeletePackage = (path: string) => {
setDeleteTarget({
type: 'package',
id: path,
name: path,
});
};
const requestDeleteSnippet = (id: string) => {
const snippet = snippets.find((item) => item.id === id);
setDeleteTarget({
type: 'snippet',
id,
name: snippet?.label || t('snippets.panel.editTitle'),
});
};
const confirmDeleteTarget = () => {
if (!deleteTarget) return;
if (deleteTarget.type === 'package') {
performDeletePackage(deleteTarget.id);
} else {
onDelete(deleteTarget.id);
setSelectedSnippetIds((prev) => {
if (!prev.has(deleteTarget.id)) return prev;
const next = new Set(prev);
next.delete(deleteTarget.id);
return next;
});
if (editingSnippet.id === deleteTarget.id) {
handleClosePanel();
}
}
setDeleteTarget(null);
};
const movePackage = (source: string, target: string | null) => {
const name = source.split('/').pop() || '';
const isAbsolute = source.startsWith('/');
@@ -1581,7 +1625,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
t={t}
handleClosePanel={handleClosePanel}
editingSnippet={editingSnippet}
onDelete={onDelete}
onDelete={requestDeleteSnippet}
handleSave={handleSave}
handleSaveAndRun={handleSaveAndRun}
setEditingSnippet={setEditingSnippet}
@@ -1869,7 +1913,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.package')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
@@ -2007,7 +2051,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDelete(snippet.id)}>
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
@@ -2071,6 +2115,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
/>
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t('vault.deleteConfirm.title', {
name: deleteTarget?.name ?? '',
})}
description={
deleteTarget?.type === 'package'
? t('vault.deleteConfirm.packageDesc')
: t('vault.deleteConfirm.desc')
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
{renderRightPanel()}
</div>
</TooltipProvider>
-1
View File
@@ -156,7 +156,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
>
+346 -52
View File
@@ -45,6 +45,12 @@ import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "../application/state/terminalAutoReconnect";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
@@ -126,6 +132,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
@@ -148,6 +155,7 @@ import {
flushTerminalSessionFlowAck,
} from "./terminal/runtime/terminalFlowAckBuffer";
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
import { flushPendingTerminalWritesBeforeHibernate } from "./terminal/runtime/terminalUnfocusedRepaint";
import {
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
@@ -180,6 +188,8 @@ import {
} from "./terminal/terminalHelpers";
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250;
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -351,6 +361,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hibernateContextScrollbackSnapshotRef = useRef("");
const hibernatePendingBufferRef = useRef("");
const hibernateAlternateScreenRef = useRef(false);
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
const wakeInProgressRef = useRef(false);
const sessionRef = useRef<string | null>(null);
const isBootActiveRef = useRef(false);
@@ -363,6 +375,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoReconnectLoopActiveRef = useRef(false);
const autoReconnectAttemptRef = useRef(0);
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
@@ -377,12 +394,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isSearchOpenRef = useRef(false);
const hibernateFileTransferActiveRef = useRef(false);
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
onUpdateHost?.(hostUpdate as Host);
}, [onUpdateHost]);
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const isRendererActive = isVisible || !resolveTerminalHibernateEnabled(terminalSettings);
const isRendererActiveRef = useRef(isRendererActive);
isRendererActiveRef.current = isRendererActive;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
@@ -560,6 +582,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
getInitialTerminalStatus()
));
const hasEverConnectedRef = useRef(status === "connected");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
@@ -636,6 +659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleFindPrevious,
handleCloseSearch,
} = terminalSearch;
isSearchOpenRef.current = isSearchOpen;
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
if (
@@ -915,7 +939,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
toast.success(t("terminal.osc7Setup.configured"));
toast.success(result.sentToTerminal
? t("terminal.osc7Setup.sent")
: t("terminal.osc7Setup.configured"));
return;
}
toast.error(result.error || t("terminal.osc7Setup.failed"));
@@ -999,14 +1025,74 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const resolvedChainHosts =
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
if (autoReconnectTimerRef.current) {
clearTimeout(autoReconnectTimerRef.current);
autoReconnectTimerRef.current = null;
}
if (options?.stopLoop !== false) {
autoReconnectLoopActiveRef.current = false;
autoReconnectAttemptRef.current = 0;
}
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
if (next === "connected") {
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
onStatusChange?.(sessionId, next);
};
}, [clearAutoReconnect, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
const shouldSchedule = trigger?.evt
? shouldAutoReconnectAfterExit({
evt: trigger.evt,
host,
terminalSettings,
hasEverConnected: hasEverConnectedRef.current,
})
: shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive: autoReconnectLoopActiveRef.current,
});
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
hasTerminalRuntime: Boolean(termRef.current),
isHibernated: hibernatedRef.current,
})) {
return false;
}
autoReconnectLoopActiveRef.current = true;
if (autoReconnectTimerRef.current) {
return true;
}
autoReconnectAttemptRef.current += 1;
const attempt = autoReconnectAttemptRef.current;
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
setError(null);
setShowLogs(true);
setIsDisconnectedDialogDismissed(false);
setProgressLogs((prev) => [...prev, scheduledMessage]);
updateStatus("connecting");
autoReconnectTimerRef.current = setTimeout(() => {
autoReconnectTimerRef.current = null;
startReconnectRef.current?.("auto");
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
return true;
}, [host, t, terminalSettings, updateStatus]);
const prepareRestoredReconnect = useCallback(() => {
if (restoreState !== "restored-disconnected") {
suppressHostStartupCommandRef.current = false;
@@ -1092,6 +1178,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasRuntimeRef.current = false;
};
const clearHibernateRuntimeState = useCallback(() => {
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [sessionId]);
const forceCloseHibernatedSession = useCallback(() => {
if (!terminalDataCapturedRef.current) {
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
@@ -1117,18 +1217,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}
sessionRef.current = null;
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
clearHibernateRuntimeState();
}, [clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
disposeDataRef.current?.();
@@ -1162,8 +1252,35 @@ const TerminalComponent: React.FC<TerminalProps> = ({
exitMessage,
);
onSessionExit?.(sessionId, evt);
scheduleAutoReconnect({ evt });
});
}, [onSessionExit, sessionId, terminalBackend]);
}, [onSessionExit, scheduleAutoReconnect, sessionId, terminalBackend]);
const clearHibernateRetry = useCallback(() => {
if (hibernateRetryTimerRef.current === null) return;
clearTimeout(hibernateRetryTimerRef.current);
hibernateRetryTimerRef.current = null;
}, []);
const scheduleHibernateRetry = useCallback(() => {
if (hibernateRetryTimerRef.current !== null) return;
hibernateRetryTimerRef.current = setTimeout(() => {
hibernateRetryTimerRef.current = null;
if (
isVisibleRef.current
|| hibernatedRef.current
|| softHiddenRef.current
|| !hasRuntimeRef.current
|| statusRef.current !== "connected"
|| isSearchOpenRef.current
|| hibernateFileTransferActiveRef.current
|| !resolveTerminalHibernateEnabled(terminalSettingsRef.current)
) {
return;
}
void fullHibernateRuntimeRef.current?.();
}, HIBERNATE_RETRY_AFTER_DRAIN_MS);
}, []);
const applyHibernateSnapshot = useCallback((
snapshot: {
@@ -1185,30 +1302,69 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hibernateAlternateScreenRef.current = snapshot.alternateScreen;
}, []);
const fullHibernateRuntime = useCallback(async () => {
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return;
const shouldSkipHibernateForActiveAlternateScreen = useCallback((term: XTerm): boolean => {
if (
!isTerminalAlternateScreenActive(term)
|| !resolveHibernateSkipAltScreen(terminalSettings)
) {
return false;
}
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
return true;
}, [sessionId, terminalSettings]);
const fullHibernateRuntime = useCallback(async (): Promise<boolean> => {
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return false;
clearHibernateRetry();
const backendId = sessionRef.current;
if (!backendId) return;
if (!backendId) return false;
const term = termRef.current;
const serializeAddon = serializeAddonRef.current;
const canFinishHibernate = () => (
!isVisibleRef.current
&& !hibernatedRef.current
&& !softHiddenRef.current
&& hasRuntimeRef.current
&& statusRef.current === "connected"
&& !isSearchOpenRef.current
&& !hibernateFileTransferActiveRef.current
&& resolveTerminalHibernateEnabled(terminalSettingsRef.current)
&& termRef.current === term
&& sessionRef.current === backendId
&& serializeAddonRef.current === serializeAddon
);
if (!canFinishHibernate()) return false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
softHiddenRef.current = false;
const flushedBeforeHibernate = await flushPendingTerminalWritesBeforeHibernate(term);
if (!flushedBeforeHibernate) {
logger.info("[Terminal] Skipping hibernate: terminal output is still draining", { sessionId });
scheduleHibernateRetry();
return false;
}
if (!canFinishHibernate()) return false;
if (shouldSkipHibernateForActiveAlternateScreen(term)) {
return false;
}
const snapshot = await serializeTerminalForHibernate(
termRef.current,
serializeAddonRef.current,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettings) },
term,
serializeAddon,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
);
if (!canFinishHibernate()) return false;
if (snapshot.alternateScreen && snapshot.snapshot.length === 0) {
logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId });
return;
return false;
}
applyHibernateSnapshot(snapshot);
isBootActiveRef.current = false;
if (termRef.current) {
releaseTerminalFlowBeforeHibernate(terminalBackend, termRef.current, backendId);
}
releaseTerminalFlowBeforeHibernate(terminalBackend, term, backendId);
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
@@ -1223,13 +1379,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
alternateScreen: snapshot.alternateScreen,
});
return true;
}, [
applyHibernateSnapshot,
beginHibernatedSessionListeners,
clearHibernateRetry,
scheduleHibernateRetry,
sessionId,
shouldSkipHibernateForActiveAlternateScreen,
terminalBackend,
terminalSettings,
]);
fullHibernateRuntimeRef.current = fullHibernateRuntime;
const hideRuntimeOnly = useCallback(() => {
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
@@ -1242,11 +1402,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hibernateRuntime = useCallback(() => {
if (hibernatedRef.current || softHiddenRef.current || !termRef.current) return;
if (
isTerminalAlternateScreenActive(termRef.current)
&& resolveHibernateSkipAltScreen(terminalSettings)
) {
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
if (shouldSkipHibernateForActiveAlternateScreen(termRef.current)) {
return;
}
@@ -1264,7 +1420,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
void fullHibernateRuntime();
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, terminalSettings]);
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalSettings]);
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
xtermRuntimeRef,
@@ -1282,6 +1438,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = false;
clearHibernateRetry();
clearAutoReconnect();
cleanupSession();
disposeRuntimeOnly();
};
@@ -1304,7 +1462,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend,
serialConfig,
telnetLocalEchoRef,
isVisibleRef,
isVisibleRef: isRendererActiveRef,
isBootActiveRef,
pendingOutputScrollRef,
sessionRef,
@@ -1371,6 +1529,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
scheduleAutoReconnect({ evt });
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: onTerminalOutput
@@ -1400,12 +1559,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionStartersRef.current = sessionStarters;
useEffect(() => {
if (status === 'disconnected') {
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
}
}, [status]);
useEffect(() => {
if (status !== "disconnected") return;
scheduleAutoReconnect();
}, [scheduleAutoReconnect, status]);
useEffect(() => {
if (!autoReconnectLoopActiveRef.current) return;
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
clearAutoReconnect();
if (hadPendingReconnect) {
updateStatus("disconnected");
}
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
useEffect(() => {
pendingScriptRunIdRef.current = null;
pendingScriptHandledRef.current = null;
@@ -1559,6 +1733,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return registerScreenSnapshotProvider(sessionId, () => {
const term = termRef.current;
if (!term?.buffer?.active) {
// Hibernated terminals: prefer last captured viewport for script sync.
const hibernatedViewport =
hibernateViewportSnapshotRef.current
|| hibernateSnapshotRef.current
|| hibernatePendingBufferRef.current;
if (hibernatedViewport) {
const lines = hibernatedViewport.split("\n");
return {
rows: Math.max(lines.length, 1),
cols: 80,
currentRow: Math.max(lines.length - 1, 0),
lines,
source: "hibernate-viewport",
};
}
return { rows: 24, cols: 80, currentRow: 0, lines: [] };
}
const buffer = term.buffer.active;
@@ -1626,7 +1815,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const safeFit = (options?: { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (!isVisibleRef.current && !options?.allowHidden) {
if (!isRendererActiveRef.current && !options?.allowHidden) {
lastFittedSizeRef.current = null;
return;
}
@@ -1672,6 +1861,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
term.resize(dimensions.cols, dimensions.rows);
forceSyncRenderAfterResize(term);
} else {
// Pixel-only layout changes (opening the SFTP side panel, compose
// bar, etc.) shrink the container without changing cols/rows, so
// term.onResize — which clears the WebGL atlas — never fires.
// Stale atlas glyphs then paint as black squares (#2013).
xtermRuntimeRef.current?.clearTextureAtlas();
forceSyncRenderAfterResize(term);
}
// Preserve scroll position across resize (superset/Tabby pattern).
@@ -1893,12 +2089,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
try {
const defaultPath = getRememberedYmodemSendDefaultPath();
const filePath = await selectFile(
t("terminal.ymodem.selectFile"),
undefined,
defaultPath,
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
);
if (!filePath) return;
rememberYmodemSendFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop() || filePath;
toast.info(t("terminal.ymodem.started", { fileName }));
@@ -1960,6 +2158,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
setIsCancelling(true);
@@ -1979,9 +2178,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
queueMicrotask(() => termRef.current?.focus());
};
const handleCloseDisconnectedSession = () => {
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
onCloseSession?.(sessionId);
@@ -2023,9 +2224,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
const startReconnect = (mode: "manual" | "auto" = "manual") => {
if (!termRef.current && mode === "auto" && hibernatedRef.current) {
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
if (!wakeForReconnect) {
updateStatus("disconnected");
return;
}
void wakeForReconnect().then((woke) => {
if (woke) {
startReconnectRef.current?.("auto");
return;
}
updateStatus("disconnected");
});
return;
}
if (!termRef.current) return;
prepareRestoredReconnect();
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = true;
}
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
@@ -2039,12 +2261,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
if (mode === "manual") {
hasRunStartupCommandRef.current = false;
}
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
updateStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setProgressLogs((prev) => (
mode === "auto"
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
: ["Retrying secure channel..."]
));
setShowLogs(true);
const startNewSession = () => {
@@ -2094,6 +2322,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
);
});
};
startReconnectRef.current = startReconnect;
const handleRetry = () => {
startReconnect("manual");
};
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
@@ -2239,7 +2472,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}) ? () => setOsc7SetupOpen(true) : undefined}
onUpdateHost={handleUpdateHostFromTerminal}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
// In a workspace, the toolbar X means "return this pane to its own tab"
// (detach), not destroy the session. Context-menu Close still kills it.
onClose={() => {
if (inWorkspace && onDetach) {
onDetach();
return;
}
onCloseSession?.(sessionId);
}}
closeRestoresTab={Boolean(inWorkspace && onDetach)}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
showLogButton
@@ -2286,6 +2528,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSessionLogging,
isWorkspaceComposeBarOpen,
onCloseSession,
onDetach,
onOpenScripts,
onOpenHistory,
onOpenTheme,
@@ -2378,15 +2621,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
safeFitRef.current({ force: true });
}, [sessionId]);
const resumeRendererAfterCancelledHibernateUpgrade = useCallback(() => {
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
xtermRuntimeRef.current?.ensureWebglRenderer();
xtermRuntimeRef.current?.clearTextureAtlas();
safeFitRef.current({ force: true });
}, []);
useEffect(() => {
return terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return;
if (!softHiddenRef.current || hibernatedRef.current) return;
softHiddenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
void fullHibernateRuntime();
// Resume the soft-hidden renderer before the asynchronous full-hibernate
// upgrade. If the pane is revealed while the upgrade is draining output
// or serializing, it then already has a live renderer instead of waiting
// on the upgrade promise to settle.
wakeSoftHiddenRuntime();
void fullHibernateRuntime().then(
(completed) => {
if (!completed) resumeRendererAfterCancelledHibernateUpgrade();
},
(error) => {
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
resumeRendererAfterCancelledHibernateUpgrade();
},
);
});
}, [fullHibernateRuntime, sessionId]);
}, [fullHibernateRuntime, resumeRendererAfterCancelledHibernateUpgrade, sessionId, wakeSoftHiddenRuntime]);
const wakeFromHibernateRuntime = useCallback((
getPayload: () => TerminalHibernateWakePayload,
@@ -2457,6 +2718,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
wakeHibernatedRuntimeForReconnectRef.current = async () => {
if (!hibernatedRef.current) {
return Boolean(termRef.current);
}
const getPayload = (): TerminalHibernateWakePayload => ({
snapshot: hibernateSnapshotRef.current,
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
pendingBuffer: hibernatePendingBufferRef.current,
alternateScreen: hibernateAlternateScreenRef.current,
});
logger.info("[Terminal] Waking hibernated runtime for auto reconnect", {
sessionId,
snapshotChars: hibernateSnapshotRef.current.length,
viewportChars: hibernateViewportSnapshotRef.current.length,
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
pendingChars: hibernatePendingBufferRef.current.length,
});
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected: false }));
if (accepted === false || !termRef.current) {
return false;
}
clearHibernateRuntimeState();
return true;
};
const hibernateFileTransferActive = isTerminalFileTransferActive({
zmodemActive: zmodem.active,
ymodemInProgress,
isDraggingOver,
});
hibernateFileTransferActiveRef.current = hibernateFileTransferActive;
useTerminalHibernateEffect({
sessionId,
isVisible,
@@ -2466,11 +2764,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: resolveTerminalHibernateEnabled(terminalSettings),
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
fileTransferActive: isTerminalFileTransferActive({
zmodemActive: zmodem.active,
ymodemInProgress,
isDraggingOver,
}),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
hibernatePendingBufferRef,
@@ -2491,7 +2785,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+11 -18
View File
@@ -65,13 +65,13 @@ import {
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
import {
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
shouldDelayAutoRunSnippetInput,
type TerminalBroadcastInputOptions,
} from './terminal/terminalHelpers';
import { Button } from './ui/button';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
@@ -417,7 +417,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const autoOpenTarget = resolveTerminalSidePanelAutoOpen({
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
enabled: terminalSidePanelAutoOpenRef.current,
selectedTab: terminalSidePanelAutoOpenTabRef.current,
sftpAvailable,
@@ -903,24 +904,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
const rawHost = hostMapRef.current.get(sessionHost.id);
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
if (usesGlobalFontSize) {
const rawHost = sessionHost ? hostMapRef.current.get(sessionHost.id) : null;
const target = resolveTerminalFontSizeUpdateTarget({ session, sessionHost, rawHost });
if (target.kind === 'none') return;
if (target.kind === 'session') {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
} else if (target.kind === 'global') {
onUpdateTerminalFontSize?.(nextFontSize);
return;
} else {
onUpdateHost({ ...target.host, fontSize: nextFontSize, fontSizeOverride: true });
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
@@ -1731,7 +1725,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpHostForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setupMcpApprovalBridge,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelPosition,
+31 -3
View File
@@ -32,12 +32,15 @@ const {
} = await import("../application/state/terminalDragData.ts");
const {
activateLogViewTab,
createSessionTopTabDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
stopCloseButtonDoubleClickPropagation,
} = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -108,14 +111,39 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createSessionTopTabDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
);
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
stopCloseButtonDoubleClickPropagation({
stopPropagation: () => {
stopPropagationCount += 1;
},
});
assert.equal(stopPropagationCount, 1);
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
+1 -1
View File
@@ -63,7 +63,7 @@ export const WindowOpacityButton: React.FC<WindowOpacityButtonProps> = ({
type="range"
min={50}
max={100}
step={5}
step={1}
value={percent}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="flex-1 accent-primary"
+7 -1
View File
@@ -559,7 +559,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
{!hideToolCalls && Array.from(pendingApprovals.entries())
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.filter(([id, req]) => {
if (!id.startsWith('mcp_approval_')) return false;
// External MCP approvals render in ExternalMcpApprovalsHost so they
// remain visible even when the Catty AI panel is closed.
if (req.chatSessionId === '__external_mcp__') return false;
return !activeSessionId || req.chatSessionId === activeSessionId;
})
.map(([id, req]) => {
return (
<React.Profiler key={id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Approval')}>
Loaded 100 of 490 files, more files were not shown because too many files have changed in this diff. Show more