Compare commits

..
244 Commits
Author SHA1 Message Date
陈大猫 5a54075ac1 Merge pull request #2147 from binaricat/fix/compact-actions-float-no-scrollbar-gutter
fix(terminal): float compact speed-dial without scrollbar gutter
2026-07-13 02:49:24 +08:00
bincxz 1bb6eb9011 fix(terminal): let script overlay cover compact speed-dial
Use full width (no right gutter) and raise z-index above the circular
toggle so a running script banner simply covers the dial instead of
dodging it.
2026-07-13 02:45:24 +08:00
bincxz b73b73a7ee fix(terminal): keep script overlay clear of compact speed-dial
When the host info bar is hidden, the script execution banner used a
fixed top:34 under empty space and full right edge under the circular
toggle. Raise it and reserve a right gutter so it sits beside the dial
instead of being pushed under it.
2026-07-13 02:44:29 +08:00
bincxz 7febde4c4d fix(terminal): polish compact pane drag handle with GripVertical
Replace the dense radial-dot “chessboard” grip in the hidden-host-info
speed-dial with a lucide GripVertical control sized like the other
toolbar icons, so drag remains available without looking like a grid
button.
2026-07-13 02:41:57 +08:00
bincxz 3f00d9f3b0 fix(terminal): float compact speed-dial without scrollbar gutter
When the host info bar is hidden, the circular action toggle is an
overlay. Reserving a right inset for it pushed the xterm scrollbar
inward and left a dead strip beside the button. Keep the normal body
inset only so the dial floats on top of the terminal.
2026-07-13 02:37:32 +08:00
陈大猫 ae7171431d Merge pull request #2146 from binaricat/chore/nix-release-metadata-v1.1.64
Update Nix release metadata for v1.1.64
2026-07-13 02:36:49 +08:00
bincxz 03f47a51d0 Update Nix release metadata for v1.1.64
Post-release automation could not push to protected main; open the
metadata bump as a PR instead.
2026-07-13 02:33:21 +08:00
陈大猫 26d113b8bf Merge pull request #2145 from binaricat/fix/host-keyword-highlight-popover-position
fix(terminal): host keyword highlight popover under compact toolbar
2026-07-13 02:17:06 +08:00
bincxz 788d525a7d fix(terminal): drop compact speed-dial shadow
Box-shadow is always sliced by the expand animation's overflow clip.
Use border-only chrome on the toggle and action pill instead.
2026-07-13 02:13:09 +08:00
bincxz 47623c7324 fix(terminal): unify compact speed-dial chrome and unclip shadow
Match toggle and action pill at h-7 with the same border/shadow treatment,
swap the dense dots for ChevronsLeft / X, and pad the expand clip box so
the soft downward shadow is no longer sliced by overflow-hidden.
2026-07-13 02:11:00 +08:00
bincxz bafb52e283 fix(terminal): actually expand compact speed-dial buttons
.terminal-topbar sets container-type:inline-size, which size-contains the
inline axis so the spring-out tray stayed width 0 and no controls appeared.
Expand with a 0fr→1fr grid instead, drop that class from the tray, shrink
the circular toggle, and use three horizontal dots.
2026-07-13 02:09:34 +08:00
bincxz 1480693ebd fix(terminal): expand compact actions as a left speed-dial
Replace the cramped top-right strip with a circular toggle. On click, the
full action toolbar springs out to the left as a rounded pill (scale +
slide + fade), so every control is visible instead of stacking on the
dots button.
2026-07-13 02:08:24 +08:00
bincxz 3634841f9f fix(terminal): drop compact actions below the ⋯ toggle
When the host info bar is hidden, the action strip used to open at the
same top-right corner as the dots toggle. That stacked the first control
(host keyword highlight) and its tooltip on the toggle, so it looked like
only “关键词高亮” appeared. Open the full toolbar as a bordered panel
under the toggle instead.
2026-07-13 02:06:37 +08:00
bincxz 6f9aa31650 fix(terminal): position host highlight popover under compact toolbar
When the host info bar is hidden, the action cluster sits at the top-right
edge. Opening host keyword highlighting with side=top clipped the panel
and left the tooltip stacked on the trigger. Open both below the toolbar,
align the panel to the end of the cluster, and suppress the tooltip while
the panel is open.
2026-07-13 02:03:37 +08:00
陈大猫 9a2d258770 Merge pull request #2144 from binaricat/chore/moshcatty-0.1.6
chore(mosh): require moshcatty-0.1.6 for prediction hardening
2026-07-13 01:38:08 +08:00
bincxz 65fb48fd44 chore(mosh): require moshcatty-0.1.6 for prediction hardening
Raise the packaging floor past 0.1.5 so Netcatty pulls host-before-ack
Confirm, Pending-continue, and related mosh-client fixes. Local resources
fetch to moshcatty-0.1.6 (binaries remain gitignored).
2026-07-13 01:33:03 +08:00
陈大猫 43fcfecc3a Merge pull request #2141 from binaricat/codex/fix-terminal-side-panel-themes
Fix terminal side panel and accent theme contrast
2026-07-13 00:08:15 +08:00
陈大猫 5e9fb24c12 Merge pull request #2140 from binaricat/codex/issue-2138-mobaxterm-encoding
Fix Chinese text in MobaXterm imports and CSV exports
2026-07-13 00:05:46 +08:00
陈大猫 54ed15b2f8 Merge pull request #2143 from binaricat/codex/react-doctor-cleanup
Triage React Doctor findings and fix timer cleanup
2026-07-13 00:02:24 +08:00
bincxz d1b8d97c0c fix: allow explicit MobaXterm import encoding 2026-07-13 00:00:52 +08:00
bincxz 857dc789fc clean up deferred UI timers 2026-07-12 23:57:10 +08:00
bincxz b58a78021d triage react doctor findings 2026-07-12 23:56:41 +08:00
bincxz 25986252fd fix side panel semantic color contrast 2026-07-12 23:54:38 +08:00
bincxz e3510f8a50 preserve valid UTF-8 import labels 2026-07-12 23:44:56 +08:00
bincxz 5d6b6b9769 fix theme contrast in terminal side panels 2026-07-12 23:40:28 +08:00
bincxz 158d5c144a preserve UTF-8 MobaXterm Chinese names 2026-07-12 23:30:00 +08:00
bincxz 783c45801b handle ambiguous MobaXterm Chinese text 2026-07-12 23:21:43 +08:00
bincxz 29c8b67958 fix Chinese text in CSV exports 2026-07-12 23:11:38 +08:00
bincxz aa2cb79c9e fix MobaXterm Chinese import encoding 2026-07-12 23:03:43 +08:00
陈大猫 ec257558c1 Merge pull request #2136 from binaricat/codex/issue-2119-system-ssh-agent
feat: support system SSH agent authentication
2026-07-12 22:58:43 +08:00
bincxz efa3d768cd fix: identify strict jump failures exactly 2026-07-12 19:21:58 +08:00
bincxz 3569cb9a87 fix: skip strict agent key prompts 2026-07-12 19:18:56 +08:00
bincxz f6de13b4aa fix: normalize SSH agent opt-out values 2026-07-12 19:11:57 +08:00
bincxz 69d8eac51c fix: re-enable imported SSH agent hosts 2026-07-12 19:09:57 +08:00
bincxz 8f970a43b8 fix: enforce strict SSH agent selection 2026-07-12 19:02:34 +08:00
bincxz c462e4f835 fix: ignore stale SSH agent checks 2026-07-12 18:56:04 +08:00
bincxz ebd5f0fc44 fix: preserve agent state for native companions 2026-07-12 18:49:16 +08:00
bincxz 40eb7b99dd fix: preserve imported key selection 2026-07-12 18:40:06 +08:00
bincxz c23dc5dd88 fix: complete strict native agent selection 2026-07-12 18:30:17 +08:00
bincxz a47df43707 fix: separate SSH agent login and forwarding 2026-07-12 18:23:07 +08:00
bincxz 55bc4559d9 fix: persist SSH agent control settings 2026-07-12 18:16:52 +08:00
bincxz f5f93cf8f5 fix: align SSH agent opt-out behavior 2026-07-12 18:05:01 +08:00
bincxz 48342ae059 fix: preserve strict SSH agent intent 2026-07-12 17:58:14 +08:00
bincxz 0046291376 fix: honor SSH agent opt-out 2026-07-12 17:49:57 +08:00
bincxz 7cb722a5c5 fix: expand SSH agent socket tokens 2026-07-12 17:39:50 +08:00
bincxz e6b4256d5d fix: preserve strict agent identity selection 2026-07-12 17:31:50 +08:00
bincxz 219f98deb5 fix: keep native SSH agent mode isolated 2026-07-12 17:24:32 +08:00
bincxz ca10c6ee92 fix: round-trip managed SSH agent settings 2026-07-12 17:17:07 +08:00
bincxz 08cc1607f9 fix: scope agent inference to macOS keychain imports 2026-07-12 17:09:19 +08:00
bincxz e979f243e0 fix: preserve AddKeysToAgent import semantics 2026-07-12 17:04:05 +08:00
bincxz a1e77dab8e fix: preserve agent identity files for key export 2026-07-12 16:48:50 +08:00
bincxz a03864da41 Merge remote-tracking branch 'origin/main' into codex/issue-2119-system-ssh-agent
# Conflicts:
#	application/state/sftp/useSftpHostCredentials.test.ts
#	application/state/useKeychainBackend.ts
#	components/HostDetailsAdvancedSections.tsx
#	components/KeychainExportPanel.tsx
#	electron/bridges/portForwardingBridge.cjs
#	infrastructure/services/portForwardingService.test.ts
2026-07-12 16:45:03 +08:00
陈大猫 80ad3f73cc Merge pull request #2133 from binaricat/codex/issue-2130-ssh-timeouts
feat: make SSH connection timeouts configurable
2026-07-12 16:36:54 +08:00
bincxz 0550bd36d5 test: keep SSH agent probe timers active 2026-07-12 16:33:06 +08:00
陈大猫 86390568fb Merge pull request #2134 from binaricat/agent/optimize-search-connect-2118
Improve quick connect flow
2026-07-12 16:29:48 +08:00
bincxz c3a364b519 fix: match Cygwin SSH agent handshake 2026-07-12 16:27:45 +08:00
bincxz 468f196a9d fix: bound Windows SSH agent probes 2026-07-12 16:23:52 +08:00
bincxz 60017871fc Merge remote-tracking branch 'origin/main' into agent/optimize-search-connect-2118
# Conflicts:
#	components/QuickConnectWizard.test.tsx
#	components/QuickConnectWizard.tsx
#	domain/quickConnectHost.test.ts
#	domain/quickConnectHost.ts
2026-07-12 16:23:21 +08:00
bincxz e578ecab4c fix: close stalled SSH agent probes 2026-07-12 16:17:01 +08:00
bincxz a5bbe2ac2f fix: harden SSH agent edge cases 2026-07-12 16:02:23 +08:00
bincxz c11502c31f fix: complete SSH agent coverage 2026-07-12 15:53:32 +08:00
bincxz af6b7645b4 feat: support system SSH agent authentication 2026-07-12 15:42:14 +08:00
bincxz daaac5f3e4 fix: cover remaining SSH timeout paths 2026-07-12 15:36:20 +08:00
陈大猫 eb3c49cdbe Merge pull request #2135 from binaricat/codex/issue-2113-credential-presets
Add credential presets to Quick Connect
2026-07-12 15:35:15 +08:00
bincxz f382677b48 Fix Telnet quick connect identity state 2026-07-12 15:25:47 +08:00
陈大猫 2fbc5f87c0 Merge pull request #2132 from binaricat/codex/issue-2108-connectivity-latency
fix: measure connectivity latency before protocol handshake
2026-07-12 15:09:15 +08:00
bincxz 64e2140b44 fix: reject invalid SSH timeout input 2026-07-12 15:06:02 +08:00
bincxz 54ceb2b85e fix: expire queued connectivity probes 2026-07-12 14:41:56 +08:00
bincxz 4233aef01a fix: bound connectivity probe scheduling 2026-07-12 14:36:30 +08:00
bincxz f42c9dbfec fix: apply host SSH timeouts consistently 2026-07-12 14:33:09 +08:00
bincxz c0abf0c3d5 fix: rate limit connectivity probes 2026-07-12 14:24:48 +08:00
bincxz f61690ab05 fix: scope SSH timeouts per host 2026-07-12 13:23:24 +08:00
bincxz 535b28a43e Add credential presets to quick connect 2026-07-12 12:50:56 +08:00
陈大猫 c096a64d7a Merge pull request #2131 from binaricat/codex/issue-2127-sftp-column-visibility
Allow hiding SFTP file list columns
2026-07-12 12:47:38 +08:00
bincxz 4631b1e037 Improve quick connect flow 2026-07-12 12:47:03 +08:00
bincxz 400a4829b9 feat: make SSH connection timeouts configurable 2026-07-12 12:42:59 +08:00
bincxz 8b104a6813 Keep SFTP column settings consistent 2026-07-12 12:34:51 +08:00
bincxz ffa2289db8 fix: measure connection latency before protocol handshake (#2108) 2026-07-12 12:33:27 +08:00
bincxz 4b3bdfb033 Allow hiding SFTP file columns 2026-07-12 12:25:15 +08:00
陈大猫 a810ec4fd4 Merge pull request #2129 from binaricat/chore/moshcatty-0.1.5-prediction
chore(mosh): require moshcatty-0.1.5 for local prediction (#2121)
2026-07-12 11:33:07 +08:00
bincxz 04981b2996 chore(mosh): require moshcatty-0.1.5 for local prediction (#2121)
Raise the packaging floor from 0.1.4 to 0.1.5 so Netcatty bundles the
stock-aligned Diff-path speculative echo fix for high-latency typing.
Handshake diagnostics already landed via #2128.
2026-07-12 11:18:22 +08:00
陈大猫 2cabefce99 Merge pull request #2128 from binaricat/fix/2121-mosh-handshake-diagnostics
fix(mosh): clarify handshake failure when MOSH CONNECT is missing
2026-07-12 11:15:27 +08:00
陈大猫 8abbd36baf Improve group SSH and Telnet defaults (#2124)
* Improve group connection defaults

* Honor credential inheritance overrides

* Preserve default identity inheritance

* Handle missing group identities

* Clear inherited credential bundles

* Reset cleared group identities

* Align group credential display

* Allow group identity with host username

* Preserve group credential overrides

* Preserve custom host credentials
2026-07-12 10:54:55 +08:00
bincxz 327ef62f90 fix(mosh): clarify handshake failure when MOSH CONNECT is missing
When SSH exits without a parsed MOSH CONNECT line, users only saw the
mosh-server banner and "[Mosh session closed (code 0)]", which looked
like a successful session that immediately died (#2121 residual
connect failures).

Print an explicit handshake-failure hint and put a matching error on
the exit payload. Document that speculative local echo lives in
MoshCatty 0.1.5+ (prediction is not implemented in the renderer).
2026-07-12 00:07:40 +08:00
陈大猫 1520040b6e Add option to hide terminal host information bar (#2125)
* Add option to hide terminal host info bar

* Preserve terminal actions and sync preference

* Make hidden terminal actions accessible

* Fix compact terminal action toggle

* Keep compact action menus accessible

* Preserve focus for compact terminal actions
2026-07-11 23:54:45 +08:00
Kushida 094743a3f8 fix(sftp): preserve hidden relative paths (#2126) 2026-07-11 22:54:52 +08:00
陈大猫 b17bc15c48 Fix silent SFTP copy when destination pane is unavailable (#2123)
* fix SFTP copy feedback without target pane

* handle inactive SFTP copy destinations

* block SFTP copy during reconnect
2026-07-11 22:52:21 +08:00
陈大猫 56216dbf47 Update Nix release metadata for v1.1.63 (#2110) 2026-07-11 04:11:17 +08:00
陈大猫 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
471 changed files with 36850 additions and 4511 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
+182 -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 Linux packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
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
+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
+236 -11
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,6 +74,9 @@ 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';
@@ -109,10 +118,12 @@ 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');
@@ -172,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,
@@ -338,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],
@@ -375,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) => ({
@@ -904,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);
@@ -940,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)),
@@ -958,6 +1007,32 @@ function App({ settings }: { settings: SettingsState }) {
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
if (host.ephemeral) {
setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
});
}
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,
@@ -974,11 +1049,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);
@@ -996,6 +1069,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));
@@ -1019,6 +1109,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) {
@@ -1084,6 +1274,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;
@@ -1189,6 +1390,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}
@@ -1206,7 +1417,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 }} />
</>
);
}
@@ -1219,26 +1430,40 @@ function AppWithProviders() {
});
useEffect(() => {
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
try {
// Hide splash screen with a fade-out animation
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
// Remove from DOM after animation completes
setTimeout(() => splash.remove(), 200);
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
}
// Notify main process that renderer is ready
netcattyBridge.get()?.rendererReady?.();
} catch {
// ignore
}
return () => {
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
};
}, []);
// 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`、
+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;
+6 -1
View File
@@ -745,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;
@@ -760,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>
+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`.',
@@ -498,6 +522,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
@@ -723,6 +749,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',
@@ -765,5 +797,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}}',
};
+1 -1
View File
@@ -41,7 +41,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Latency',
'systemManager.overview.latency': 'SSH network latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
+4 -1
View File
@@ -55,6 +55,8 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.detach': 'Detach to standalone tab',
'terminal.toolbar.dragPane': 'Drag terminal pane',
'terminal.toolbar.showActions': 'Show terminal actions',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -88,7 +90,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',
@@ -301,6 +303,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',
+44 -3
View File
@@ -50,6 +50,14 @@ export const enVaultMessages: Messages = {
'vault.import.toast.summary':
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
'vault.import.toast.firstIssue': 'First issue: {issue}',
'vault.import.mobaxterm.chooseEncoding': 'Choose how to read your MobaXterm file.',
'vault.import.mobaxterm.encodingQuestion': 'Which text encoding does this file use?',
'vault.import.mobaxterm.auto': 'Auto Detect',
'vault.import.mobaxterm.autoDesc': 'Recommended. Uses UTF-8 when valid, otherwise Chinese Windows encoding.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
@@ -166,6 +174,7 @@ export const enVaultMessages: Messages = {
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.configure': 'Choose visible columns',
'sftp.columns.actions': 'Actions',
'sftp.emptyDirectory': 'Empty directory',
'sftp.nav.up': 'Go up',
@@ -186,6 +195,7 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
'sftp.copyCurrentPath.error': 'Could not copy current path',
@@ -279,6 +289,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 +395,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',
@@ -472,6 +488,17 @@ export const enVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
'quickConnect.identity.label': 'Credential preset',
'quickConnect.identity.placeholder': 'Choose a saved identity',
'quickConnect.identity.empty': 'No saved identities found',
'quickConnect.identity.hint': 'Choose an identity to fill in its username and authentication automatically.',
'quickConnect.identity.selectedHint': 'Ready to connect as {username}.',
'quickConnect.identity.method.password': 'Password',
'quickConnect.identity.method.key': 'SSH key',
'quickConnect.identity.method.certificate': 'Certificate',
'quickConnect.identity.unavailable': 'This identity is missing usable credentials on this device.',
'quickConnect.identity.useCustom': 'Use custom credentials',
'quickConnect.identity.connect': 'Connect with preset',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',
@@ -618,6 +645,15 @@ export const enVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.section.systemSshAgent': 'System SSH Agent',
'hostDetails.systemSshAgent': 'Use System SSH Agent to Sign In',
'hostDetails.systemSshAgent.desc': 'Use keys already unlocked by macOS, Linux, Windows, 1Password, or another compatible SSH agent',
'hostDetails.systemSshAgent.socket': 'Agent Socket',
'hostDetails.systemSshAgent.socket.desc': 'Leave empty to use SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Only Use Configured Keys',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Do not try unrelated keys currently loaded in the agent',
'hostDetails.systemSshAgent.notRunning': 'System SSH agent is unavailable',
'hostDetails.systemSshAgent.notRunningHint': 'Start or unlock your SSH agent, or provide its socket path above.',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
@@ -659,6 +695,11 @@ export const enVaultMessages: Messages = {
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.section.sshTimeouts': 'SSH Connection Timeouts',
'hostDetails.sshTimeouts.tcpConnect': 'TCP timeout',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+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',
+35
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`.',
@@ -471,6 +495,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
@@ -725,11 +751,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}}',
};
+1 -1
View File
@@ -41,7 +41,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Задержка',
'systemManager.overview.latency': 'Сетевая задержка SSH',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
+4
View File
@@ -76,6 +76,8 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
'terminal.toolbar.showActions': 'Показать действия терминала',
'terminal.toolbar.encoding': 'Кодировка терминала',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -109,6 +111,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': 'Перетащите для вставки путей',
@@ -316,6 +319,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',
+44 -3
View File
@@ -85,6 +85,14 @@ export const ruVaultMessages: Messages = {
'vault.import.toast.summary':
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
'vault.import.mobaxterm.chooseEncoding': 'Выберите способ чтения файла MobaXterm.',
'vault.import.mobaxterm.encodingQuestion': 'Какая кодировка используется в этом файле?',
'vault.import.mobaxterm.auto': 'Определить автоматически',
'vault.import.mobaxterm.autoDesc': 'Рекомендуется. UTF-8, если он корректен, иначе китайская кодировка Windows.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
'vault.import.sshConfig.importOnly': 'Только импорт',
@@ -201,6 +209,7 @@ export const ruVaultMessages: Messages = {
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
'sftp.columns.kind': 'Тип',
'sftp.columns.configure': 'Выбрать видимые столбцы',
'sftp.columns.actions': 'Действия',
'sftp.emptyDirectory': 'Пустой каталог',
'sftp.nav.up': 'Наверх',
@@ -221,6 +230,7 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
@@ -314,6 +324,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 +430,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': 'Автосинхронизация с удалённым сервером',
@@ -507,6 +523,17 @@ export const ruVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
'quickConnect.addKey': 'Добавить ключ',
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
'quickConnect.identity.label': 'Набор учётных данных',
'quickConnect.identity.placeholder': 'Выберите сохранённый профиль',
'quickConnect.identity.empty': 'Сохранённые профили не найдены',
'quickConnect.identity.hint': 'Выберите профиль, чтобы автоматически подставить имя пользователя и способ входа.',
'quickConnect.identity.selectedHint': 'Готово к подключению как {username}.',
'quickConnect.identity.method.password': 'Пароль',
'quickConnect.identity.method.key': 'Ключ SSH',
'quickConnect.identity.method.certificate': 'Сертификат',
'quickConnect.identity.unavailable': 'Для этого профиля нет доступных учётных данных на устройстве.',
'quickConnect.identity.useCustom': 'Использовать другие данные',
'quickConnect.identity.connect': 'Подключиться с профилем',
// Terminal
'terminal.connectionErrorTitle': 'Ошибка подключения',
@@ -650,6 +677,15 @@ export const ruVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Обзор...',
'hostDetails.credential.missing': 'Учётные данные не найдены',
'hostDetails.section.systemSshAgent': 'Системный SSH-агент',
'hostDetails.systemSshAgent': 'Использовать системный SSH-агент для входа',
'hostDetails.systemSshAgent.desc': 'Использовать ключи, разблокированные macOS, Linux, Windows, 1Password или другим совместимым агентом',
'hostDetails.systemSshAgent.socket': 'Сокет агента',
'hostDetails.systemSshAgent.socket.desc': 'Оставьте пустым, чтобы использовать SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Только настроенные ключи',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Не пробовать другие ключи, загруженные в агент',
'hostDetails.systemSshAgent.notRunning': 'Системный SSH-агент недоступен',
'hostDetails.systemSshAgent.notRunningHint': 'Запустите или разблокируйте SSH-агент либо укажите путь к его сокету выше.',
'hostDetails.keys.search': 'Поиск ключей...',
'hostDetails.keys.empty': 'Нет доступных ключей',
'hostDetails.certs.search': 'Поиск сертификатов...',
@@ -691,6 +727,11 @@ export const ruVaultMessages: Messages = {
'hostDetails.keepalive.interval': 'Интервал (секунды)',
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
'hostDetails.section.sshTimeouts': 'Тайм-ауты SSH-подключения',
'hostDetails.sshTimeouts.tcpConnect': 'Тайм-аут TCP',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -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 文件夹',
+37
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',
@@ -506,6 +530,14 @@ export const zhCNCoreMessages: Messages = {
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
'vault.import.toast.firstIssue': '首个问题:{issue}',
'vault.import.mobaxterm.chooseEncoding': '选择如何读取 MobaXterm 文件。',
'vault.import.mobaxterm.encodingQuestion': '这个文件使用哪种文字编码?',
'vault.import.mobaxterm.auto': '自动检测',
'vault.import.mobaxterm.autoDesc': '推荐。能按 UTF-8 读取时使用 UTF-8,否则使用中文 Windows 编码。',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
@@ -595,6 +627,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.configure': '选择显示的列',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
@@ -618,6 +651,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
'sftp.copyCurrentPath.error': '无法复制当前路径',
@@ -711,6 +745,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': '所有者',
@@ -41,7 +41,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': '延迟',
'systemManager.overview.latency': 'SSH 网络延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
+19 -3
View File
@@ -8,6 +8,8 @@ export const zhCNTerminalMessages: Messages = {
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
'terminal.toolbar.configureOsc7': '配置目录追踪',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.toolbar.dragPane': '拖动终端窗格',
'terminal.toolbar.showActions': '显示终端操作',
'terminal.menu.detach': '从工作区移出',
'terminal.osc7Setup.title': '配置目录追踪',
'terminal.osc7Setup.desc': 'Netcatty 会为当前远端用户添加 OSC 7 提示符配置。这样在 sudo 或 su 之后,SFTP 也能继续跟随终端目录。',
@@ -104,9 +106,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': '自动同步到远程',
@@ -250,9 +255,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 默认行为)。关闭则保留历史。',
@@ -345,6 +359,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
'settings.terminal.section.serverStats': '服务器状态(Linux)',
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
+27 -1
View File
@@ -50,6 +50,17 @@ export const zhCNVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': '加入并继续',
'quickConnect.addKey': '添加 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
'quickConnect.identity.label': '凭据预设',
'quickConnect.identity.placeholder': '选择已保存的身份',
'quickConnect.identity.empty': '没有可用的身份',
'quickConnect.identity.hint': '选择身份后会自动带入用户名和认证信息。',
'quickConnect.identity.selectedHint': '将以 {username} 连接。',
'quickConnect.identity.method.password': '密码',
'quickConnect.identity.method.key': 'SSH 密钥',
'quickConnect.identity.method.certificate': '证书',
'quickConnect.identity.unavailable': '此身份在当前设备上缺少可用凭据。',
'quickConnect.identity.useCustom': '改用自定义凭据',
'quickConnect.identity.connect': '使用预设连接',
// Protocol select dialog
'protocolSelect.chooseProtocol': '选择协议',
@@ -192,6 +203,15 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '浏览…',
'hostDetails.credential.missing': '凭据不存在',
'hostDetails.section.systemSshAgent': '系统 SSH 密钥',
'hostDetails.systemSshAgent': '使用系统 SSH 密钥登录',
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他兼容服务解锁的密钥',
'hostDetails.systemSshAgent.socket': '代理地址',
'hostDetails.systemSshAgent.socket.desc': '留空时使用 SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': '仅使用已配置的密钥',
'hostDetails.systemSshAgent.identitiesOnly.desc': '不尝试代理中其他无关密钥',
'hostDetails.systemSshAgent.notRunning': '系统 SSH 密钥服务不可用',
'hostDetails.systemSshAgent.notRunningHint': '请启动或解锁密钥服务,或者在上方填写它的地址。',
'hostDetails.keys.search': '搜索密钥…',
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
@@ -233,6 +253,11 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.section.sshTimeouts': 'SSH 连接超时',
'hostDetails.sshTimeouts.tcpConnect': 'TCP 连接超时(秒)',
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -382,7 +407,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': '拖放以插入路径',
@@ -577,6 +602,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 資料夾',
+37
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',
@@ -506,6 +530,14 @@ export const zhTWCoreMessages: Messages = {
'vault.import.toast.noNewHosts': '從 {format} 沒有匯入到新的主機。',
'vault.import.toast.summary': '已匯入 {count} 個主機(跳過 {skipped},重複 {duplicates})。',
'vault.import.toast.firstIssue': '首個問題:{issue}',
'vault.import.mobaxterm.chooseEncoding': '選擇如何讀取 MobaXterm 檔案。',
'vault.import.mobaxterm.encodingQuestion': '這個檔案使用哪種文字編碼?',
'vault.import.mobaxterm.auto': '自動偵測',
'vault.import.mobaxterm.autoDesc': '建議。能以 UTF-8 讀取時使用 UTF-8,否則使用中文 Windows 編碼。',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
'vault.import.sshConfig.importOnly': '僅匯入',
@@ -595,6 +627,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
'sftp.bookmark.empty': '暫無收藏路徑',
'sftp.columns.name': '名稱',
'sftp.columns.configure': '選擇顯示的欄位',
'sftp.columns.modified': '修改時間',
'sftp.columns.size': '大小',
'sftp.columns.kind': '型別',
@@ -618,6 +651,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
'sftp.copyCurrentPath.success': '已複製目前路徑',
'sftp.copyCurrentPath.error': '無法複製目前路徑',
@@ -711,6 +745,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': '所有者',
@@ -41,7 +41,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.overview.system': '系統',
'systemManager.overview.kernel': '核心',
'systemManager.overview.swap': '交換區',
'systemManager.overview.latency': '延遲',
'systemManager.overview.latency': 'SSH 網路延遲',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
+19 -3
View File
@@ -8,6 +8,8 @@ export const zhTWTerminalMessages: Messages = {
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.toolbar.dragPane': '拖動終端窗格',
'terminal.toolbar.showActions': '顯示終端操作',
'terminal.menu.detach': '從工作區移出',
'terminal.osc7Setup.title': '設定目錄追蹤',
'terminal.osc7Setup.desc': 'Netcatty 會為目前遠端使用者新增 OSC 7 提示字元設定。這樣在 sudo 或 su 之後,SFTP 也能繼續跟隨終端目錄。',
@@ -104,9 +106,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': '自動同步到遠端',
@@ -250,9 +255,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 預設行為)。關閉則保留歷史。',
@@ -345,6 +359,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示地址。留空則使用系統預設值。',
'settings.terminal.connection.x11Display.placeholder': '自動(:0 或 DISPLAY)',
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.serverStats.show': '顯示伺服器狀態',
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
+27 -1
View File
@@ -50,6 +50,17 @@ export const zhTWVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': '加入並繼續',
'quickConnect.addKey': '新增 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 引數已被忽略: {options}',
'quickConnect.identity.label': '憑據預設',
'quickConnect.identity.placeholder': '選擇已儲存的身分',
'quickConnect.identity.empty': '沒有可用的身分',
'quickConnect.identity.hint': '選擇身分後會自動帶入使用者名稱和驗證資訊。',
'quickConnect.identity.selectedHint': '將以 {username} 連線。',
'quickConnect.identity.method.password': '密碼',
'quickConnect.identity.method.key': 'SSH 金鑰',
'quickConnect.identity.method.certificate': '憑證',
'quickConnect.identity.unavailable': '此身分在目前裝置上缺少可用憑據。',
'quickConnect.identity.useCustom': '改用自訂憑據',
'quickConnect.identity.connect': '使用預設連線',
// Protocol select dialog
'protocolSelect.chooseProtocol': '選擇協定',
@@ -192,6 +203,15 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '瀏覽…',
'hostDetails.credential.missing': '憑證不存在',
'hostDetails.section.systemSshAgent': '系統 SSH 金鑰',
'hostDetails.systemSshAgent': '使用系統 SSH 金鑰登入',
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他相容服務解鎖的金鑰',
'hostDetails.systemSshAgent.socket': '代理位址',
'hostDetails.systemSshAgent.socket.desc': '留空時使用 SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': '僅使用已設定的金鑰',
'hostDetails.systemSshAgent.identitiesOnly.desc': '不嘗試代理中其他無關金鑰',
'hostDetails.systemSshAgent.notRunning': '系統 SSH 金鑰服務無法使用',
'hostDetails.systemSshAgent.notRunningHint': '請啟動或解鎖金鑰服務,或在上方填寫其位址。',
'hostDetails.keys.search': '搜尋金鑰…',
'hostDetails.keys.empty': '暫無金鑰',
'hostDetails.certs.search': '搜尋憑證…',
@@ -233,6 +253,11 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.keepalive.interval': '間隔(秒)',
'hostDetails.keepalive.countMax': '最大無響應保活次數',
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
'hostDetails.section.sshTimeouts': 'SSH 連線逾時',
'hostDetails.sshTimeouts.tcpConnect': 'TCP 連線逾時(秒)',
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
@@ -382,7 +407,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': '拖放以插入路徑',
@@ -577,6 +602,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 設定',
+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 };
}
@@ -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,
+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,91 @@ 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 forwards system agent settings for target and jump hosts", () => {
const jump = host({
id: "jump-1",
hostname: "jump.example.com",
useSshAgent: true,
identityFilePaths: ["~/.ssh/jump"],
identitiesOnly: true,
});
const credentials = buildSftpHostCredentials({
host: host({
hostChain: { hostIds: ["jump-1"] },
useSshAgent: true,
identityAgent: "$SSH_AUTH_SOCK",
identityFilePaths: ["~/.ssh/aws_root"],
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: true,
}),
hosts: [jump],
keys: [],
identities: [],
});
assert.deepEqual(
{
useSshAgent: credentials.useSshAgent,
identityAgent: credentials.identityAgent,
identityFilePaths: credentials.identityFilePaths,
identitiesOnly: credentials.identitiesOnly,
addKeysToAgent: credentials.addKeysToAgent,
useKeychain: credentials.useKeychain,
},
{
useSshAgent: true,
identityAgent: "$SSH_AUTH_SOCK",
identityFilePaths: ["~/.ssh/aws_root"],
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: true,
},
);
assert.equal(credentials.jumpHosts?.[0]?.useSshAgent, true);
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["~/.ssh/jump"]);
assert.equal(credentials.jumpHosts?.[0]?.identitiesOnly, true);
});
test("buildSftpHostCredentials forwards target and jump-host timeouts", () => {
const jumpHost = host({
id: "jump-1",
sshTcpConnectTimeoutSeconds: 75,
sshAuthReadyTimeoutSeconds: 360,
});
const credentials = buildSftpHostCredentials({
host: host({
hostChain: { hostIds: ["jump-1"] },
sshTcpConnectTimeoutSeconds: 45,
sshAuthReadyTimeoutSeconds: 300,
}),
hosts: [jumpHost],
keys: [],
identities: [],
});
assert.equal(credentials.sshTcpConnectTimeoutMs, 45_000);
assert.equal(credentials.sshAuthReadyTimeoutMs, 300_000);
assert.equal(credentials.jumpHosts?.[0]?.sshTcpConnectTimeoutMs, 75_000);
assert.equal(credentials.jumpHosts?.[0]?.sshAuthReadyTimeoutMs, 360_000);
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
@@ -495,3 +583,47 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials uses the system agent when a synced key cannot be decrypted", () => {
const key: SSHKey = {
id: "key-1",
label: "Synced key",
type: "ED25519",
publicKey: "ssh-ed25519 AAAASELECTED",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "key",
identityFileId: "key-1",
useSshAgent: true,
}),
hosts: [],
keys: [key],
identities: [],
});
assert.equal(credentials.useSshAgent, true);
assert.deepEqual(credentials.agentPublicKeys, ["ssh-ed25519 AAAASELECTED"]);
assert.equal(credentials.privateKey, undefined);
});
test("imported IdentityFile paths remain usable when agent login is disabled", () => {
const credentials = buildSftpHostCredentials({
host: host({
identityFilePaths: ["~/.ssh/id_work"],
useSshAgent: false,
identityAgent: "none",
}),
hosts: [],
keys: [],
identities: [],
});
assert.equal(credentials.useSshAgent, false);
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
});
@@ -1,8 +1,9 @@
import { useCallback } from "react";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import {
findIncompleteProxyIdentityId,
findMissingProxyIdentityId,
@@ -30,6 +31,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,
@@ -82,12 +100,15 @@ export const buildSftpHostCredentials = ({
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
const jumpKeyAuth = resolveBridgeKeyAuth({
key: jumpKey,
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
fallbackIdentityFilePaths: (!jumpHost.useSshAgent && jumpAuth.authMethod === "password") || jumpAuth.keyId
? undefined
: jumpHost.identityFilePaths,
passphrase: jumpAuth.passphrase,
});
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey);
const hasJumpKeyMaterial = Boolean(
jumpAgentAuth.useSshAgent || jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length,
);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
hasUsableProxyConfig(jumpHost.proxyConfig);
@@ -108,6 +129,7 @@ export const buildSftpHostCredentials = ({
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings);
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -124,8 +146,11 @@ export const buildSftpHostCredentials = ({
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
...jumpAgentAuth,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
legacyAlgorithms: jumpHost.legacyAlgorithms,
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
@@ -140,13 +165,16 @@ export const buildSftpHostCredentials = ({
const keyAuth = resolveBridgeKeyAuth({
key,
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
fallbackIdentityFilePaths: (!host.useSshAgent && resolved.authMethod === "password") || resolved.keyId
? undefined
: host.identityFilePaths,
passphrase: resolved.passphrase,
});
const targetAgentAuth = resolveBridgeSshAgentAuth(host, key);
const password = sanitizeCredentialValue(resolved.password);
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
const hasKeyMaterial = Boolean(
targetAgentAuth.useSshAgent || keyAuth.privateKey || keyAuth.identityFilePaths?.length,
);
const hasUnreadableCredential =
isEncryptedCredentialPlaceholder(resolved.password) ||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
@@ -159,6 +187,7 @@ export const buildSftpHostCredentials = ({
}
const targetKeepalive = resolveHostKeepalive(host, globalTerminalSettings);
const targetConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
return {
hostname: host.hostname,
username: resolved.username,
@@ -174,8 +203,11 @@ export const buildSftpHostCredentials = ({
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
...targetAgentAuth,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
sshTcpConnectTimeoutMs: targetConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: targetConnectionTimeouts.authReadyTimeoutSeconds * 1000,
knownHosts,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
// Algorithm settings — must reach the SFTP bridge or hosts that need
+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(() => {
@@ -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,
};
+7 -3
View File
@@ -33,12 +33,16 @@ export const useApplicationBackend = () => {
return info ?? null;
}, []);
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
const checkSshAgent = useCallback(async (options?: {
identityAgent?: string;
hostname?: string;
port?: number;
username?: string;
}): Promise<SshAgentStatus | null> => {
const bridge = netcattyBridge.get();
const status = await bridge?.checkSshAgent?.();
const status = await bridge?.checkSshAgent?.(options);
return status ?? null;
}, []);
return { openExternal, getApplicationInfo, checkSshAgent };
};
@@ -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 };
}
+1 -19
View File
@@ -7,24 +7,7 @@ export const useKeychainBackend = () => {
return bridge?.generateKeyPair?.(options);
}, []);
const execCommand = useCallback(async (options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
// Algorithm settings — let the keychain "export public key" flow honor
// the same per-host SSH algorithm config the terminal uses, so a host
// that needs the ECDSA skip / legacy mode / advanced overrides works
// here too.
legacyAlgorithms?: boolean;
skipEcdsaHostKey?: boolean;
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
}) => {
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
return bridge.execCommand(options);
@@ -32,4 +15,3 @@ export const useKeychainBackend = () => {
return { generateKeyPair, execCommand };
};
@@ -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(
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models.ts";
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
const host: Host = {
id: "host-1",
label: "Managed host",
hostname: "managed.example.com",
username: "root",
port: 22,
protocol: "ssh",
os: "linux",
tags: [],
};
test("managed SSH source comparison tracks every agent setting", () => {
const changedFields: Array<keyof Host> = [
"useSshAgent",
"identityAgent",
"identitiesOnly",
"addKeysToAgent",
"useKeychain",
];
for (const field of changedFields) {
const changed = {
...host,
[field]: field === "identityAgent" || field === "addKeysToAgent"
? "changed"
: true,
};
assert.equal(haveSameManagedSshAgentFields(host, changed), false, field);
}
assert.equal(haveSameManagedSshAgentFields(host, { ...host }), true);
});
@@ -15,6 +15,14 @@ export interface UseManagedSourceSyncOptions {
onUpdateManagedSources: (sources: ManagedSource[]) => void;
}
export const haveSameManagedSshAgentFields = (previous: Host, current: Host): boolean => (
previous.useSshAgent === current.useSshAgent
&& previous.identityAgent === current.identityAgent
&& previous.identitiesOnly === current.identitiesOnly
&& previous.addKeysToAgent === current.addKeysToAgent
&& previous.useKeychain === current.useKeychain
);
export const useManagedSourceSync = ({
hosts,
managedSources,
@@ -285,6 +293,7 @@ export const useManagedSourceSync = ({
prev.label !== curr.label ||
prev.group !== curr.group ||
prev.protocol !== curr.protocol ||
!haveSameManagedSshAgentFields(prev, curr) ||
chainChanged;
if (hasChanged) {
sourceChanged = true;
+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,
]),
};
};
+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();
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import test from "node:test";
import { importVaultHostsFromText } from "../../domain/vaultImport.ts";
import { readVaultImportFile } from "./vaultImportFile.ts";
const sessionValue = "#109#0%10.0.0.1%22%root";
test("MobaXterm import decodes legacy GB18030 Chinese text", async () => {
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
const suffix = new TextEncoder().encode(`=${sessionValue}`);
const encodedLabel = new Uint8Array([
0xd6, 0xd0, 0xce, 0xc4, 0xb7, 0xfe, 0xce, 0xf1, 0xc6, 0xf7,
]);
const bytes = new Uint8Array(prefix.length + encodedLabel.length + suffix.length);
bytes.set(prefix);
bytes.set(encodedLabel, prefix.length);
bytes.set(suffix, prefix.length + encodedLabel.length);
const text = await readVaultImportFile(
"mobaxterm",
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
);
const result = importVaultHostsFromText("mobaxterm", text);
assert.equal(result.hosts[0]?.label, "中文服务器");
});
test("MobaXterm import keeps unmarked UTF-8 Chinese text", async () => {
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n北京上海=${sessionValue}`;
const decoded = await readVaultImportFile(
"mobaxterm",
new File([text], "MobaXterm.ini", { type: "text/plain" }),
);
const result = importVaultHostsFromText("mobaxterm", decoded);
assert.equal(result.hosts[0]?.label, "北京上海");
});
test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chinese", async () => {
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n¡prod=${sessionValue}`;
const decoded = await readVaultImportFile(
"mobaxterm",
new File([text], "MobaXterm.ini", { type: "text/plain" }),
);
const result = importVaultHostsFromText("mobaxterm", decoded);
assert.equal(result.hosts[0]?.label, "¡prod");
});
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
const bytes = new Uint8Array(prefix.length + 2 + suffix.length);
bytes.set(prefix);
bytes.set([0xc2, 0xa1], prefix.length);
bytes.set(suffix, prefix.length + 2);
const decoded = await readVaultImportFile(
"mobaxterm",
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
"gb18030",
);
const result = importVaultHostsFromText("mobaxterm", decoded);
assert.equal(result.hosts[0]?.label, "隆prod");
});
+14
View File
@@ -0,0 +1,14 @@
import type { VaultImportFormat } from "../../domain/vaultImport";
import { readTextFile } from "../../lib/readTextFile";
export type VaultImportFileEncoding = "auto" | "utf-8" | "gb18030";
export const readVaultImportFile = (
format: VaultImportFormat,
file: File,
encoding: VaultImportFileEncoding = "auto",
): Promise<string> => {
if (format !== "mobaxterm") return readTextFile(file);
if (encoding !== "auto") return readTextFile(file, { encoding });
return readTextFile(file, { fallbackEncoding: "gb18030" });
};
+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' };
}
+75
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,
@@ -951,6 +955,44 @@ test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", ()
assert.equal(termSettings.fallbackFont, "PingFang SC");
});
test("buildSyncPayload includes the terminal host information bar preference", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ showHostInfoBar: false }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.showHostInfoBar, false);
});
test("applySyncPayload restores the terminal host information bar preference", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { showHostInfoBar: false } },
};
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.showHostInfoBar, false);
assert.equal(parsed.scrollback, 5000);
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
@@ -983,6 +1025,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,
+4 -2
View File
@@ -200,12 +200,14 @@ 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',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode', 'showServerStats',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
'showHostInfoBar', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
+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}
+103 -1
View File
@@ -1,10 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
import {
hasGroupSshFields,
hasGroupTelnetFields,
includeMissingIdentityOption,
resolveGroupFormIdentityId,
selectGroupSshIdentity,
selectGroupTelnetIdentity,
} from "./GroupDetailsPanel.tsx";
test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", () => {
assert.equal(hasGroupSshFields({ identityId: "" }), true);
assert.equal(hasGroupSshFields({}), false);
});
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetIdentityId: "identity-1" }), true);
assert.equal(hasGroupTelnetFields({}), false);
});
test("GroupDetailsPanel replaces manual SSH credentials with a reusable identity", () => {
const result = selectGroupSshIdentity(
{
username: "manual",
password: "secret",
identityFileId: "key-1",
identityFilePaths: ["~/.ssh/id_ed25519"],
},
{
id: "identity-1",
label: "Shared admin",
username: "admin",
authMethod: "password",
created: 1,
},
);
assert.equal(result.identityId, "identity-1");
assert.equal(result.username, "admin");
assert.equal(result.password, undefined);
assert.equal(result.identityFileId, undefined);
assert.equal(result.identityFilePaths, undefined);
});
test("GroupDetailsPanel replaces manual Telnet credentials with a reusable identity", () => {
const result = selectGroupTelnetIdentity(
{ telnetUsername: "manual", telnetPassword: "secret" },
"identity-1",
);
assert.equal(result.telnetIdentityId, "identity-1");
assert.equal(result.telnetUsername, undefined);
assert.equal(result.telnetPassword, undefined);
});
test("GroupDetailsPanel explicitly clears inherited identities for manual credentials", () => {
const ssh = selectGroupSshIdentity({}, undefined, "", "parent-ssh-identity");
const telnet = selectGroupTelnetIdentity({}, "", "parent-telnet-identity");
assert.equal(ssh.identityId, "");
assert.equal(telnet.telnetIdentityId, "");
});
test("GroupDetailsPanel clears key authentication before switching to a manual password", () => {
const selected = selectGroupSshIdentity(
{},
{
id: "key-identity",
label: "Key identity",
username: "admin",
authMethod: "key",
keyId: "key-1",
created: 1,
},
);
const cleared = selectGroupSshIdentity(selected, undefined);
const manual = { ...cleared, username: "operator", password: "secret" };
assert.equal(manual.identityId, undefined);
assert.equal(manual.authMethod, undefined);
assert.equal(manual.username, "operator");
assert.equal(manual.password, "secret");
});
test("GroupDetailsPanel keeps a deleted identity visible so it can be cleared", () => {
const options = includeMissingIdentityOption([], "deleted-identity", "Identity not found");
assert.deepEqual(options, [{ value: "deleted-identity", label: "Identity not found" }]);
});
test("GroupDetailsPanel shows saved child manual credentials instead of a parent identity", () => {
assert.equal(
resolveGroupFormIdentityId(
{ username: "child-user", password: "child-password" },
"parent-ssh-identity",
"ssh",
),
undefined,
);
assert.equal(
resolveGroupFormIdentityId(
{ telnetUsername: "child-user", telnetPassword: "child-password" },
"parent-telnet-identity",
"telnet",
),
undefined,
);
});
+181 -39
View File
@@ -14,7 +14,12 @@ import {
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { customThemeStore } from "../application/state/customThemeStore";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import {
hasManualGroupSshCredentials,
hasManualGroupTelnetCredentials,
resolveGroupDefaults,
resolveGroupTerminalThemeId,
} from "../domain/groupConfig";
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
@@ -54,6 +59,7 @@ import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -85,10 +91,81 @@ type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResiz
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
c.telnetPort !== undefined ||
c.telnetIdentityId !== undefined ||
c.telnetUsername !== undefined ||
c.telnetPassword !== undefined ||
c.telnetEnabled === true;
export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
Boolean(c.identityFilePaths && c.identityFilePaths.length > 0);
export const selectGroupSshIdentity = (
form: Partial<GroupConfig>,
identity: Identity | undefined,
identityId = identity?.id || "",
inheritedIdentityId?: string,
): Partial<GroupConfig> => {
if (!identityId) {
return {
...form,
identityId: inheritedIdentityId ? "" : undefined,
username: undefined,
authMethod: undefined,
};
}
if (!identity) return { ...form, identityId };
return {
...form,
identityId,
username: identity.username,
authMethod: identity.authMethod,
password: undefined,
savePassword: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
};
};
export const selectGroupTelnetIdentity = (
form: Partial<GroupConfig>,
identityId: string,
inheritedIdentityId?: string,
): Partial<GroupConfig> => ({
...form,
telnetIdentityId: identityId || (inheritedIdentityId ? "" : undefined),
...(identityId ? { telnetUsername: undefined, telnetPassword: undefined } : {}),
});
export const includeMissingIdentityOption = (
options: Array<{ value: string; label: string; sublabel?: string }>,
identityId: string | undefined,
missingLabel: string,
): Array<{ value: string; label: string; sublabel?: string }> => {
if (!identityId || options.some((option) => option.value === identityId)) return options;
return [{ value: identityId, label: missingLabel }, ...options];
};
export const resolveGroupFormIdentityId = (
form: Partial<GroupConfig>,
inheritedIdentityId: string | undefined,
protocol: "ssh" | "telnet",
): string | undefined => {
const formIdentityId = protocol === "ssh" ? form.identityId : form.telnetIdentityId;
if (formIdentityId !== undefined) return formIdentityId;
const hasManualCredentials = protocol === "ssh"
? hasManualGroupSshCredentials(form)
: hasManualGroupTelnetCredentials(form);
return hasManualCredentials ? undefined : inheritedIdentityId;
};
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
groupPath,
config,
@@ -130,18 +207,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const [nameError, setNameError] = useState<string | null>(null);
// Protocol sections enabled state
const hasSshFields = (c: Partial<GroupConfig>) =>
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
const [sshEnabled, setSshEnabled] = useState(() => hasGroupSshFields(config || {}));
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
// Sub-panel state
@@ -220,6 +286,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const next = { ...prev };
delete next.telnetEnabled;
delete next.telnetPort;
delete next.telnetIdentityId;
delete next.telnetUsername;
delete next.telnetPassword;
return next;
@@ -320,6 +387,65 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
};
}, [availableKeys]);
const identityOptions = useMemo(
() => identities.map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
})),
[identities],
);
const inheritedConnectionDefaults = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return {};
return resolveGroupDefaults(parentGroup, groupConfigs);
}, [groupConfigs, parentGroup]);
const effectiveSshIdentityId = resolveGroupFormIdentityId(
form,
inheritedConnectionDefaults.identityId,
"ssh",
);
const effectiveTelnetIdentityId = resolveGroupFormIdentityId(
form,
inheritedConnectionDefaults.telnetIdentityId,
"telnet",
);
const sshIdentityOptions = useMemo(
() => includeMissingIdentityOption(
identityOptions,
effectiveSshIdentityId,
t("hostDetails.identity.missing"),
),
[effectiveSshIdentityId, identityOptions, t],
);
const telnetIdentityOptions = useMemo(
() => includeMissingIdentityOption(
identityOptions,
effectiveTelnetIdentityId,
t("hostDetails.identity.missing"),
),
[effectiveTelnetIdentityId, identityOptions, t],
);
const updateSshIdentity = useCallback((identityId: string) => {
setForm((prev) => selectGroupSshIdentity(
prev,
identities.find((item) => item.id === identityId),
identityId,
inheritedConnectionDefaults.identityId,
));
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
}, [identities, inheritedConnectionDefaults.identityId]);
const updateTelnetIdentity = useCallback((identityId: string) => {
setForm((prev) => selectGroupTelnetIdentity(
prev,
identityId,
inheritedConnectionDefaults.telnetIdentityId,
));
}, [inheritedConnectionDefaults.telnetIdentityId]);
// Parent group options — exclude self and children
const parentGroupOptions = useMemo(() => {
const selfPath = groupPath;
@@ -453,6 +579,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
...(telnetEnabled && {
telnetEnabled: true,
...(form.telnetPort !== undefined && { telnetPort: form.telnetPort }),
...(form.telnetIdentityId !== undefined && { telnetIdentityId: form.telnetIdentityId }),
...(form.telnetUsername !== undefined && { telnetUsername: form.telnetUsername }),
...(form.telnetPassword !== undefined && { telnetPassword: form.telnetPassword }),
}),
@@ -630,6 +757,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
showPassword={showPassword}
setShowPassword={setShowPassword}
availableKeys={availableKeys}
identities={identities}
identityOptions={sshIdentityOptions}
updateSshIdentity={updateSshIdentity}
effectiveSshIdentityId={effectiveSshIdentityId}
setSelectedCredentialType={setSelectedCredentialType}
selectedCredentialType={selectedCredentialType}
credentialPopoverOpen={credentialPopoverOpen}
@@ -715,28 +846,40 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
</div>
</div>
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.telnetUsername || ""}
onChange={(e) => update("telnetUsername", e.target.value)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showTelnetPassword ? "text" : "password"}
value={form.telnetPassword || ""}
onChange={(e) => update("telnetPassword", e.target.value)}
className="h-10 pr-10"
{(identities.length > 0 || effectiveTelnetIdentityId) && (
<Combobox
options={telnetIdentityOptions}
value={effectiveTelnetIdentityId || ""}
onValueChange={updateTelnetIdentity}
placeholder={t("hostDetails.identity.suggestions")}
emptyText={t("common.noResultsFound")}
className="w-full"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{!effectiveTelnetIdentityId && (<>
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.telnetUsername || ""}
onChange={(e) => update("telnetUsername", e.target.value)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showTelnetPassword ? "text" : "password"}
value={form.telnetPassword || ""}
onChange={(e) => update("telnetPassword", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</>)}
</HostDetailsSection>
)}
@@ -746,11 +889,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
icon={<Globe size={14} className="text-muted-foreground" />}
title={t("vault.groups.details.advanced")}
>
<Input
placeholder="UTF-8"
value={form.charset || ""}
onChange={(e) => update("charset", e.target.value || undefined)}
className="h-10"
<TerminalEncodingSelect
value={form.charset}
inheritedValue={inheritedConnectionDefaults.charset}
onValueChange={(value) => update("charset", value)}
/>
</HostDetailsSection>
+45 -28
View File
@@ -34,6 +34,10 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
showPassword,
setShowPassword,
availableKeys,
identities,
identityOptions,
updateSshIdentity,
effectiveSshIdentityId,
setSelectedCredentialType,
selectedCredentialType,
credentialPopoverOpen,
@@ -100,32 +104,45 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
</div>
</div>
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.username || ""}
onChange={(e) => update("username", e.target.value || undefined)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value || undefined)}
className="h-10 pr-10"
{(identities.length > 0 || effectiveSshIdentityId) && (
<Combobox
options={identityOptions}
value={effectiveSshIdentityId || ""}
onValueChange={updateSshIdentity}
placeholder={t("hostDetails.identity.suggestions")}
emptyText={t("common.noResultsFound")}
className="w-full"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{!effectiveSshIdentityId && (<>
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.username || ""}
onChange={(e) => update("username", e.target.value || undefined)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value || undefined)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</>)}
{/* Selected credential display */}
{form.identityFileId && (
{!effectiveSshIdentityId && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
{form.authMethod === "certificate" ? (
<Shield size={14} className="text-primary" />
@@ -151,7 +168,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
)}
{/* Local key file paths display */}
{!form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
{!effectiveSshIdentityId && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 h-8 px-2 rounded-md bg-secondary/50 border border-border/60" style={{ maxWidth: '100%' }}>
@@ -175,7 +192,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
)}
{/* Credential type selection with inline popover - hidden when credential is selected */}
{!form.identityFileId &&
{!effectiveSshIdentityId && !form.identityFileId &&
!selectedCredentialType && (
<Popover
open={credentialPopoverOpen}
@@ -244,7 +261,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* Key selection combobox - appears after selecting "Key" type */}
{selectedCredentialType === "key" &&
!form.identityFileId && (
!effectiveSshIdentityId && !form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
@@ -277,7 +294,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
{selectedCredentialType === "certificate" &&
!form.identityFileId && (
!effectiveSshIdentityId && !form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.certificate.map((k) => ({
@@ -312,7 +329,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
)}
{/* Local key file path input - appears after selecting "Local Key File" type */}
{!form.identityFileId && selectedCredentialType === "localKeyFile" && (
{!effectiveSshIdentityId && !form.identityFileId && selectedCredentialType === "localKeyFile" && (
<div className="space-y-1.5">
<div className="flex items-center gap-1 w-full">
<input
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./HostDetailsAdvancedSections.tsx', import.meta.url), 'utf8');
test('advanced host settings expose per-host SSH connection timeouts', () => {
assert.match(source, /hostDetails\.section\.sshTimeouts/);
assert.match(source, /value=\{form\.sshTcpConnectTimeoutSeconds \?\?/);
assert.match(source, /update\("sshTcpConnectTimeoutSeconds", value\)/);
assert.match(source, /value=\{form\.sshAuthReadyTimeoutSeconds \?\?/);
assert.match(source, /update\("sshAuthReadyTimeoutSeconds", value\)/);
assert.equal(source.match(/!Number\.isFinite\(value\)/g)?.length, 2);
});
test('editing enabled SSH agent controls persists the enabled state', () => {
assert.match(source, /enabled=\{form\.useSshAgent === true\}/);
assert.match(source, /useSshAgent: true,\s*identityAgent:/);
assert.match(source, /useSshAgent: true,\s*identitiesOnly:/);
});
test('enabling SSH agent login clears an imported none sentinel', () => {
assert.match(source, /enabling && isSshAgentNoneValue\(previous\.identityAgent\)/);
});
+113 -1
View File
@@ -1,7 +1,8 @@
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Wifi, X, Variable } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
import { isSshAgentNoneValue } from "../domain/sshAgentSettings";
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
import { Badge } from "./ui/badge";
@@ -13,6 +14,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import {
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
MAX_SSH_CONNECTION_TIMEOUT_SECONDS,
} from "../domain/sshConnectionTimeouts";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HostDetailsAdvancedSectionsProps = Record<string, any>;
@@ -229,6 +235,70 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
)}
</HostDetailsSection>
{/* System SSH Agent login */}
<HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.systemSshAgent")}
>
<ToggleRow
label={t("hostDetails.systemSshAgent")}
hint={t("hostDetails.systemSshAgent.desc")}
enabled={form.useSshAgent === true}
onToggle={() => setForm((previous: typeof form) => {
const enabling = previous.useSshAgent !== true;
return {
...previous,
useSshAgent: enabling,
identityAgent: enabling && isSshAgentNoneValue(previous.identityAgent)
? undefined
: previous.identityAgent,
};
})}
/>
{form.useSshAgent === true && (
<>
<HostDetailsSettingRow
label={t("hostDetails.systemSshAgent.socket")}
hint={t("hostDetails.systemSshAgent.socket.desc")}
>
<Input
className="w-44 font-mono text-xs"
placeholder="$SSH_AUTH_SOCK"
value={form.identityAgent ?? ""}
onChange={(event) => setForm((previous: typeof form) => ({
...previous,
useSshAgent: true,
identityAgent: event.target.value || undefined,
}))}
/>
</HostDetailsSettingRow>
<ToggleRow
label={t("hostDetails.systemSshAgent.identitiesOnly")}
hint={t("hostDetails.systemSshAgent.identitiesOnly.desc")}
enabled={!!form.identitiesOnly}
onToggle={() => setForm((previous: typeof form) => ({
...previous,
useSshAgent: true,
identitiesOnly: !previous.identitiesOnly,
}))}
/>
</>
)}
{form.useSshAgent === true && sshAgentStatus && !sshAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{t("hostDetails.systemSshAgent.notRunning")}
</p>
<p className="text-xs text-muted-foreground">
{t("hostDetails.systemSshAgent.notRunningHint")}
</p>
</div>
</div>
)}
</HostDetailsSection>
{/* Agent Forwarding */}
<HostDetailsSection
icon={<Forward size={14} className="text-muted-foreground" />}
@@ -456,6 +526,48 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
)}
</HostDetailsSection>
<HostDetailsSection
icon={<Timer size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.sshTimeouts")}
>
<HostDetailsSettingRow
label={t("hostDetails.sshTimeouts.tcpConnect")}
hint={t("hostDetails.sshTimeouts.tcpConnect.desc")}
>
<Input
type="number"
min={1}
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
className="h-8 w-20 text-xs"
aria-label={t("hostDetails.sshTimeouts.tcpConnect")}
value={form.sshTcpConnectTimeoutSeconds ?? DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS}
onChange={(e) => {
const value = Number.parseInt(e.target.value, 10);
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
update("sshTcpConnectTimeoutSeconds", value);
}}
/>
</HostDetailsSettingRow>
<HostDetailsSettingRow
label={t("hostDetails.sshTimeouts.authReady")}
hint={t("hostDetails.sshTimeouts.authReady.desc")}
>
<Input
type="number"
min={1}
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
className="h-8 w-20 text-xs"
aria-label={t("hostDetails.sshTimeouts.authReady")}
value={form.sshAuthReadyTimeoutSeconds ?? DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS}
onChange={(e) => {
const value = Number.parseInt(e.target.value, 10);
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
update("sshAuthReadyTimeoutSeconds", value);
}}
/>
</HostDetailsSettingRow>
</HostDetailsSection>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<HostDetailsSection
icon={<Link2 size={14} className="text-muted-foreground" />}
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./HostDetailsPanel.tsx', import.meta.url), 'utf8');
test('SSH agent availability ignores stale async responses', () => {
assert.match(source, /let cancelled = false/);
assert.match(source, /if \(!cancelled\) setSshAgentStatus\(status\)/);
assert.match(source, /return \(\) => \{\s*cancelled = true/);
});
+19 -8
View File
@@ -53,6 +53,7 @@ import {
prepareProxyConfigForSave,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
import { Button } from "./ui/button";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
@@ -187,12 +188,23 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
} | null>(null);
useEffect(() => {
if (form.agentForwarding) {
checkSshAgent().then(setSshAgentStatus);
let cancelled = false;
if (form.agentForwarding || form.useSshAgent === true) {
void checkSshAgent({
identityAgent: form.useSshAgent === true ? form.identityAgent : undefined,
hostname: form.hostname,
port: form.port,
username: form.username,
}).then((status) => {
if (!cancelled) setSshAgentStatus(status);
});
} else {
setSshAgentStatus(null);
}
}, [form.agentForwarding, checkSshAgent]);
return () => {
cancelled = true;
};
}, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
@@ -1094,11 +1106,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</>
)}
<Input
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
value={form.charset || "UTF-8"}
onChange={(e) => update("charset", e.target.value)}
className="h-10"
<TerminalEncodingSelect
value={form.charset}
inheritedValue={effectiveGroupDefaults?.charset}
onValueChange={(value) => update("charset", value)}
/>
<button
+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
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./KeychainExportPanel.tsx', import.meta.url), 'utf8');
test('key export forwards the selected host SSH connection timeouts', () => {
assert.match(source, /resolveHostSshConnectionTimeouts\(effectiveExportHost\)/);
assert.match(source, /sshTcpConnectTimeoutMs: connectionTimeouts\.tcpConnectTimeoutSeconds \* 1000/);
assert.match(source, /sshAuthReadyTimeoutMs: connectionTimeouts\.authReadyTimeoutSeconds \* 1000/);
});
test('key export preserves imported identity files when the system SSH agent is enabled', () => {
assert.match(
source,
/fallbackIdentityFilePaths: \(!effectiveExportHost\.useSshAgent && exportAuth\.authMethod === "password"\) \|\| exportAuth\.keyId/,
);
});
test('key export passes the selected vault key to agent filtering', () => {
assert.match(source, /resolveBridgeSshAgentAuth\(\s*effectiveExportHost,\s*exportAuth\.key,\s*\)/);
});
+27 -20
View File
@@ -2,7 +2,8 @@ import React from "react";
import { ChevronRight, Info } from "lucide-react";
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
@@ -172,26 +173,40 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
setIsExporting(true);
try {
// Apply group defaults before resolving authentication
// and connection settings for this one-off SSH command.
const effectiveExportHost = exportHost.group
? applyGroupDefaults(
exportHost,
resolveGroupDefaults(exportHost.group, groupConfigs),
)
: applyGroupDefaults(exportHost, {});
const connectionTimeouts = resolveHostSshConnectionTimeouts(effectiveExportHost);
const exportAuth = resolveHostAuth({
host: exportHost,
host: effectiveExportHost,
keys,
identities,
});
const exportKeyAuth = resolveBridgeKeyAuth({
key: exportAuth.key,
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
fallbackIdentityFilePaths: (!effectiveExportHost.useSshAgent && exportAuth.authMethod === "password") || exportAuth.keyId
? undefined
: exportHost.identityFilePaths,
: effectiveExportHost.identityFilePaths,
passphrase: exportAuth.passphrase,
});
const exportPassword = sanitizeCredentialValue(exportAuth.password);
const exportAgentAuth = resolveBridgeSshAgentAuth(
effectiveExportHost,
exportAuth.key,
);
// Need either password or a usable key to run remote command.
if (
!exportPassword &&
!exportKeyAuth.privateKey &&
!exportKeyAuth.identityFilePaths?.length
) {
if (!hasBridgeSshCredentials({
password: exportPassword,
privateKey: exportKeyAuth.privateKey,
identityFilePaths: exportKeyAuth.identityFilePaths,
useSshAgent: exportAgentAuth.useSshAgent,
})) {
throw new Error(
t("keychain.export.missingCredentials"),
);
@@ -212,17 +227,6 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
// Execute the script directly - SSH exec handles multiline commands
const command = scriptWithVars;
// Resolve the effective host (applying group
// defaults), so algorithm settings inherited from
// the group reach the bridge — the host object on
// its own only carries explicitly set fields.
const effectiveExportHost = exportHost.group
? applyGroupDefaults(
exportHost,
resolveGroupDefaults(exportHost.group, groupConfigs),
)
: applyGroupDefaults(exportHost, {});
// Execute via SSH
const result = await execCommand({
hostname: effectiveExportHost.hostname,
@@ -236,6 +240,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
keySource: exportAuth.key?.source,
passphrase: exportKeyAuth.passphrase,
identityFilePaths: exportKeyAuth.identityFilePaths,
...exportAgentAuth,
// Carry the effective host's algorithm settings
// (host value falling back to its group default)
// so the one-off SSH exec honors them just like
@@ -245,6 +250,8 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
algorithmOverrides: effectiveExportHost.algorithms,
command,
timeout: 30000,
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
enableKeyboardInteractive: true,
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
});
+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>
);
};
+13 -12
View File
@@ -155,7 +155,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
}
// Fit terminal
setTimeout(() => {
const initialFitTimer = setTimeout(() => {
try {
fitAddon.fit();
} catch {
@@ -186,6 +186,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
// Cleanup
return () => {
clearTimeout(initialFitTimer);
term.dispose();
termRef.current = null;
fitAddonRef.current = null;
@@ -205,17 +206,17 @@ const LogViewComponent: React.FC<LogViewProps> = ({
// Update font size instantly without recreating terminal
useEffect(() => {
if (termRef.current && isReady) {
termRef.current.options.fontSize = currentFontSize;
// Refit after font size change
setTimeout(() => {
try {
fitAddonRef.current?.fit();
} catch {
// Ignore fit errors
}
}, 10);
}
if (!termRef.current || !isReady) return;
termRef.current.options.fontSize = currentFontSize;
// Refit after font size change
const refitTimer = setTimeout(() => {
try {
fitAddonRef.current?.fit();
} catch {
// Ignore fit errors
}
}, 10);
return () => clearTimeout(refitTimer);
}, [currentFontSize, isReady]);
// Handle resize
+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>
);
};
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import QuickConnectWizard from "./QuickConnectWizard";
test("QuickConnectWizard offers ET without obsolete Mosh path or log controls", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<QuickConnectWizard
open
target={{ hostname: "example.com" }}
keys={[]}
identities={[]}
onConnect={() => undefined}
onClose={() => undefined}
/>
</I18nProvider>,
);
assert.match(markup, /Eternal Terminal/);
assert.doesNotMatch(markup, /mosh --server/);
assert.doesNotMatch(markup, /Show logs|Hide logs/);
});
test("quick connect shows saved identities as credential presets", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<QuickConnectWizard
open
target={{ hostname: "192.0.2.10" }}
keys={[]}
identities={[{
id: "identity-root",
label: "Root devices",
username: "root",
authMethod: "password",
password: "secret",
created: 1,
}]}
onConnect={() => undefined}
onClose={() => undefined}
/>
</I18nProvider>,
);
assert.match(markup, /Credential preset/);
assert.match(markup, /Choose a saved identity/);
});
+248 -88
View File
@@ -14,16 +14,22 @@ import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import {
buildQuickConnectHost,
getQuickConnectDefaultPort,
isQuickConnectIdentityUsable,
type QuickConnectAuthMethod,
type QuickConnectProtocol,
} from "../domain/quickConnectHost";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Host, Identity, SSHKey } from "../types";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { ScrollArea } from "./ui/scroll-area";
// Protocol types supported for quick connect
type Protocol = "ssh" | "mosh" | "telnet";
// Wizard steps
type WizardStep = "protocol" | "username" | "knownhost" | "auth";
@@ -31,6 +37,7 @@ interface QuickConnectWizardProps {
open: boolean;
target: QuickConnectTarget;
keys: SSHKey[];
identities: Identity[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
@@ -42,6 +49,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
open,
target,
keys,
identities,
warnings,
onConnect,
onSaveHost,
@@ -51,11 +59,9 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const { t } = useI18n();
// Wizard state
const [step, setStep] = useState<WizardStep>("protocol");
const [protocol, setProtocol] = useState<Protocol>("ssh");
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
const [username, setUsername] = useState(target.username || "");
const [port, setPort] = useState<number>(target.port || 22);
const [moshServerPath, setMoshServerPath] = useState("");
const [showLogs, setShowLogs] = useState(false);
// Known host verification state
const [knownHostInfo, setKnownHostInfo] = useState<{
@@ -64,11 +70,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
} | null>(null);
// Auth state
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
const [authMethod, setAuthMethod] = useState<QuickConnectAuthMethod>("password");
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials] = useState(true);
const selectedIdentity = useMemo(
() => identities.find((identity) => identity.id === selectedIdentityId),
[identities, selectedIdentityId],
);
// Reset state when target changes
React.useEffect(() => {
@@ -79,38 +90,82 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
setPort(target.port || 22);
setPassword("");
setSelectedKeyId(null);
setSelectedIdentityId(null);
setSaveOptionsOpen(false);
setShowPassword(false);
setKnownHostInfo(null);
}
}, [open, target]);
// Get default port for protocol
const getDefaultPort = (proto: Protocol) => {
switch (proto) {
case "ssh":
return 22;
case "mosh":
return 22;
case "telnet":
return 23;
default:
return 22;
const canUseSelectedIdentity = useMemo(
() => isQuickConnectIdentityUsable(selectedIdentity, keys, protocol),
[keys, protocol, selectedIdentity],
);
const identityOptions = useMemo(
() => identities.map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
icon: <User size={14} />,
})),
[identities],
);
const detachSelectedIdentity = () => {
setSelectedIdentityId(null);
setAuthMethod("password");
setPassword("");
setSelectedKeyId(null);
};
const clearSelectedIdentity = () => {
detachSelectedIdentity();
setUsername(target.username || "");
};
const handleIdentitySelect = (identityId: string) => {
if (!identityId) {
clearSelectedIdentity();
return;
}
const identity = identities.find((candidate) => candidate.id === identityId);
if (!identity) return;
setSelectedIdentityId(identity.id);
setUsername(identity.username);
setAuthMethod(identity.authMethod);
setPassword("");
setSelectedKeyId(identity.keyId || null);
};
// Handle protocol selection
const handleProtocolSelect = (proto: Protocol) => {
const handleProtocolSelect = (proto: QuickConnectProtocol) => {
setProtocol(proto);
// Update port to default for protocol if unchanged
if (port === getDefaultPort(protocol)) {
setPort(getDefaultPort(proto));
if (proto === "telnet") {
if (selectedIdentityId) clearSelectedIdentity();
setAuthMethod("password");
}
if (port === getQuickConnectDefaultPort(protocol)) {
setPort(getQuickConnectDefaultPort(proto));
}
};
const handleProtocolPortChange = (
proto: QuickConnectProtocol,
rawValue: string,
fallbackPort: number,
) => {
handleProtocolSelect(proto);
setPort(parseInt(rawValue) || fallbackPort);
};
// Navigate to next step
const handleContinue = () => {
switch (step) {
case "protocol":
if (canUseSelectedIdentity) {
handleConnect();
break;
}
// Always go to username step to let user confirm/edit username
setStep("username");
break;
@@ -143,33 +198,25 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
};
// Create host and connect
const handleConnect = () => {
const handleConnect = (save = false) => {
const effectiveUsername = username || target.username || "root";
const effectivePort = port || getDefaultPort(protocol);
const tempHost: Host = {
id: `quick-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
label: `${target.hostname}`,
hostname: target.hostname,
const effectivePort = port || getQuickConnectDefaultPort(protocol);
const now = Date.now();
const tempHost = buildQuickConnectHost({
id: `quick-${now}-${Math.random().toString(36).slice(2, 11)}`,
createdAt: now,
target,
protocol,
port: effectivePort,
username: effectiveUsername,
group: "",
tags: [],
os: "linux",
protocol: protocol === "mosh" ? "ssh" : protocol,
authMethod: authMethod,
password: authMethod === "password" ? password : undefined,
identityFileId:
authMethod === "key" ? selectedKeyId || undefined : undefined,
moshEnabled: protocol === "mosh",
// Set telnet-specific fields when using telnet protocol
telnetEnabled: protocol === "telnet",
telnetPort: protocol === "telnet" ? effectivePort : undefined,
createdAt: Date.now(),
};
authMethod,
password,
selectedKeyId,
selectedIdentityId: selectedIdentity?.id,
save,
});
// Save host if requested
if (saveCredentials && onSaveHost) {
if (save && onSaveHost) {
onSaveHost(tempHost);
}
@@ -187,12 +234,24 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "knownhost":
return true;
case "auth":
if (selectedIdentity && protocol !== "telnet") return canUseSelectedIdentity;
if (authMethod === "password") {
return password.trim().length > 0;
// Whitespace-only passwords are valid SSH secrets (issue #2036).
return password.length > 0;
}
return !!selectedKeyId;
return Boolean(selectedKeyId && keys.some((key) => key.id === selectedKeyId));
}
}, [step, username, authMethod, password, selectedKeyId]);
}, [
step,
username,
authMethod,
password,
selectedKeyId,
selectedIdentity,
canUseSelectedIdentity,
keys,
protocol,
]);
// Render protocol selection step
const renderProtocolStep = () => (
@@ -232,10 +291,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
<Input
type="number"
value={protocol === "ssh" ? port : 22}
onChange={(e) => {
setPort(parseInt(e.target.value) || 22);
setProtocol("ssh");
}}
onChange={(e) => handleProtocolPortChange("ssh", e.target.value, 22)}
onClick={(e) => e.stopPropagation()}
className="w-16 h-7 text-xs text-center"
min={1}
@@ -277,22 +333,53 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
<Input
type="number"
value={protocol === "mosh" ? port : 22}
onChange={(e) => {
setPort(parseInt(e.target.value) || 22);
setProtocol("mosh");
}}
onChange={(e) => handleProtocolPortChange("mosh", e.target.value, 22)}
onClick={(e) => e.stopPropagation()}
className="w-16 h-7 text-xs text-center"
min={1}
max={65535}
/>
</div>
</button>
{/* Eternal Terminal */}
<button
className={cn(
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
protocol === "et"
? "border-primary bg-primary/5"
: "border-border/60 hover:border-border hover:bg-secondary/50",
)}
onClick={() => handleProtocolSelect("et")}
>
<div className="flex items-center gap-3">
<div
className={cn(
"h-10 w-10 rounded-lg flex items-center justify-center",
protocol === "et"
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground",
)}
>
<Globe size={18} />
</div>
<div>
<div className="font-medium">Eternal Terminal</div>
<div className="text-xs text-muted-foreground font-mono">
et {target.hostname}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
<Input
type="text"
value={moshServerPath}
onChange={(e) => setMoshServerPath(e.target.value)}
type="number"
value={protocol === "et" ? port : 22}
onChange={(e) => handleProtocolPortChange("et", e.target.value, 22)}
onClick={(e) => e.stopPropagation()}
placeholder="mosh --server=/path/server host"
className="w-40 h-7 text-xs"
className="w-16 h-7 text-xs text-center"
min={1}
max={65535}
/>
</div>
</button>
@@ -330,10 +417,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
<Input
type="number"
value={protocol === "telnet" ? port : 23}
onChange={(e) => {
setPort(parseInt(e.target.value) || 23);
setProtocol("telnet");
}}
onChange={(e) => handleProtocolPortChange("telnet", e.target.value, 23)}
onClick={(e) => e.stopPropagation()}
className="w-16 h-7 text-xs text-center"
min={1}
@@ -342,6 +426,25 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div>
</button>
</div>
{protocol !== "telnet" && identities.length > 0 && (
<div className="space-y-2 pt-1">
<Label>{t("quickConnect.identity.label")}</Label>
<Combobox
options={identityOptions}
value={selectedIdentityId || undefined}
onValueChange={handleIdentitySelect}
placeholder={t("quickConnect.identity.placeholder")}
emptyText={t("quickConnect.identity.empty")}
icon={<User size={14} />}
/>
<p className="text-xs text-muted-foreground">
{selectedIdentity
? t("quickConnect.identity.selectedHint", { username: selectedIdentity.username })
: t("quickConnect.identity.hint")}
</p>
</div>
)}
</div>
);
@@ -353,7 +456,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
<Input
id="quick-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onChange={(e) => {
if (selectedIdentityId) detachSelectedIdentity();
setUsername(e.target.value);
}}
placeholder={t("terminal.auth.username.placeholder")}
autoFocus
onKeyDown={(e) => {
@@ -392,6 +498,30 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
// Render auth step
const renderAuthStep = () => (
<div className="space-y-4">
{selectedIdentity ? (
<div className="rounded-xl border border-primary/40 bg-primary/5 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<User size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{selectedIdentity.label}</div>
<div className="text-xs text-muted-foreground truncate">
{selectedIdentity.username} · {t(`quickConnect.identity.method.${selectedIdentity.authMethod}`)}
</div>
</div>
</div>
{!canUseSelectedIdentity && (
<p className="text-xs text-destructive">
{t("quickConnect.identity.unavailable")}
</p>
)}
<Button variant="outline" size="sm" className="w-full" onClick={detachSelectedIdentity}>
{t("quickConnect.identity.useCustom")}
</Button>
</div>
) : (
<>
{/* Auth method tabs */}
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
@@ -401,7 +531,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("password")}
onClick={() => {
setSelectedIdentityId(null);
setAuthMethod("password");
}}
>
<Lock size={14} />
{t("terminal.auth.password")}
@@ -409,11 +542,14 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "key"
authMethod !== "password"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("key")}
onClick={() => {
setSelectedIdentityId(null);
setAuthMethod("key");
}}
>
<Key size={14} />
{t("terminal.auth.sshKey")}
@@ -434,7 +570,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();
}
}}
@@ -471,7 +607,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50",
)}
onClick={() => setSelectedKeyId(key.id)}
onClick={() => {
setSelectedIdentityId(null);
setSelectedKeyId(key.id);
}}
>
<div
className={cn(
@@ -508,6 +647,8 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
)}
</div>
)}
</>
)}
</div>
);
@@ -620,16 +761,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</p>
</div>
</div>
{(step === "auth" || (step === "knownhost" && !knownHostInfo)) && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? "Hide logs" : "Show logs"}
</Button>
)}
</div>
</div>
@@ -693,14 +824,43 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
{t("quickConnect.addKey")}
</Button>
)}
<Button disabled={!canProceed} onClick={handleConnect}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
<Dropdown open={saveOptionsOpen} onOpenChange={setSaveOptionsOpen}>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
disabled={!canProceed}
onClick={() => handleConnect(false)}
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
>
{t("common.continue")}
</Button>
<DropdownTrigger asChild>
<Button
disabled={!canProceed}
aria-label={t("terminal.auth.continueSave")}
aria-haspopup="menu"
aria-expanded={saveOptionsOpen}
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44 p-1" align="end" side="top">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => handleConnect(true)}
disabled={!canProceed}
>
{t("terminal.auth.continueSave")}
</button>
</DropdownContent>
</Dropdown>
</div>
) : (
<Button onClick={handleContinue} disabled={!canProceed}>
{t("common.continue")}
{step === "protocol" && canUseSelectedIdentity
? t("quickConnect.identity.connect")
: t("common.continue")}
</Button>
)}
</div>
+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();
+7
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
import type { Snippet } from "../types";
@@ -12,6 +13,8 @@ const snippet = (overrides: Partial<Snippet>): Snippet => ({
order: overrides.order,
});
const source = readFileSync(new URL("./ScriptsSidePanel.tsx", import.meta.url), "utf8");
test("scripts side panel rows keep manual snippet order inside a package", () => {
const rows = buildScriptsSidePanelRows({
snippets: [
@@ -28,3 +31,7 @@ test("scripts side panel rows keep manual snippet order inside a package", () =>
["zulu", "beta", "alpha"],
);
});
test("scripts side panel active tabs pair the accent background with its foreground", () => {
assert.equal(source.match(/bg-accent text-accent-foreground/g)?.length, 2);
});
+2 -2
View File
@@ -616,7 +616,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
subView === 'library' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
subView === 'library' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('library')}
>
@@ -626,7 +626,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
subView === 'running' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
subView === 'running' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('running')}
>
+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 />
</>
);
};
+16
View File
@@ -53,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[];
@@ -100,6 +103,7 @@ interface SftpSidePanelProps {
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
sessions = [],
keys,
identities,
knownHosts = [],
@@ -133,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 }) => {
@@ -480,6 +490,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpSidePanelInteractiveBody
hosts={hosts}
hostWriteSource={hostWriteSource}
connectedHosts={connectedHosts}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
@@ -522,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>>;
@@ -561,6 +573,7 @@ type SftpSidePanelInteractiveBodyProps = {
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
hosts,
hostWriteSource,
connectedHosts,
updateHosts,
sftp,
sftpRef,
@@ -1051,6 +1064,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
return (
<SftpContextProvider
hosts={hosts}
connectedHosts={connectedHosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
@@ -1138,6 +1152,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
{renderOverlays && (
<SftpOverlays
hosts={hosts}
connectedHosts={connectedHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
@@ -1186,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')}
>
+167 -33
View File
@@ -42,6 +42,7 @@ import {
type TerminalContextReader,
} from "../domain/terminalContextRead";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
@@ -71,7 +72,6 @@ import {
import { useCustomThemes } from "../application/state/customThemeStore";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { SSH_TCP_CONNECT_TIMEOUT_MS } from "./terminal/connectionTimeouts";
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
@@ -155,6 +155,7 @@ import {
flushTerminalSessionFlowAck,
} from "./terminal/runtime/terminalFlowAckBuffer";
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
import { flushPendingTerminalWritesBeforeHibernate } from "./terminal/runtime/terminalUnfocusedRepaint";
import {
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
@@ -187,6 +188,9 @@ import {
} from "./terminal/terminalHelpers";
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250;
const EMPTY_CHAIN_HOSTS: Host[] = [];
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -195,7 +199,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippetPackages = [],
compactToolbar = false,
lineTimestampsAvailable = true,
chainHosts = [],
chainHosts = EMPTY_CHAIN_HOSTS,
appearanceTheme,
knownHosts = [],
isVisible,
@@ -278,7 +282,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
deferTerminalResizeRef.current = deferTerminalResize;
// Initial TCP dial timeout. Authentication prompts use their own backend timeout.
const CONNECTION_TIMEOUT = SSH_TCP_CONNECT_TIMEOUT_MS;
const hostConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
const CONNECTION_TIMEOUT = hostConnectionTimeouts.tcpConnectTimeoutSeconds * 1000;
const { t } = useI18n();
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
@@ -358,6 +363,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);
@@ -389,12 +396,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);
@@ -599,6 +611,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentHop: number;
totalHops: number;
currentHostLabel: string;
connectionPhase: string;
} | null>(null);
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
@@ -649,6 +662,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleFindPrevious,
handleCloseSearch,
} = terminalSearch;
isSearchOpenRef.current = isSearchOpen;
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
if (
@@ -1245,6 +1259,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [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: {
snapshot: string;
@@ -1265,30 +1305,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?.();
@@ -1303,13 +1382,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;
@@ -1322,11 +1405,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;
}
@@ -1344,7 +1423,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
void fullHibernateRuntime();
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, terminalSettings]);
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalSettings]);
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
xtermRuntimeRef,
@@ -1362,6 +1441,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = false;
clearHibernateRetry();
clearAutoReconnect();
cleanupSession();
disposeRuntimeOnly();
@@ -1385,7 +1465,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend,
serialConfig,
telnetLocalEchoRef,
isVisibleRef,
isVisibleRef: isRendererActiveRef,
isBootActiveRef,
pendingOutputScrollRef,
sessionRef,
@@ -1656,6 +1736,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;
@@ -1723,7 +1818,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;
}
@@ -1769,6 +1864,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).
@@ -2373,7 +2475,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
@@ -2420,6 +2531,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSessionLogging,
isWorkspaceComposeBarOpen,
onCloseSession,
onDetach,
onOpenScripts,
onOpenHistory,
onOpenTheme,
@@ -2512,15 +2624,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,
@@ -2621,6 +2751,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return true;
};
const hibernateFileTransferActive = isTerminalFileTransferActive({
zmodemActive: zmodem.active,
ymodemInProgress,
isDraggingOver,
});
hibernateFileTransferActiveRef.current = hibernateFileTransferActive;
useTerminalHibernateEffect({
sessionId,
isVisible,
@@ -2630,11 +2767,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,
@@ -2651,7 +2784,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
@@ -2662,6 +2795,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onResume={() => { void resumeScriptRun(activeScriptRun.runId); }}
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
<ScriptSaveRecordingDialog
+21
View File
@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getTerminalEncodingOptions,
resolveTerminalEncodingSelectValue,
} from "./TerminalEncodingSelect.tsx";
test("terminal encoding selector advertises the supported encodings", () => {
assert.deepEqual(getTerminalEncodingOptions(), ["UTF-8", "GB18030"]);
});
test("terminal encoding selector preserves an existing custom encoding", () => {
assert.deepEqual(getTerminalEncodingOptions("Shift_JIS"), ["Shift_JIS", "UTF-8", "GB18030"]);
});
test("terminal encoding selector normalizes supported values to their displayed option", () => {
assert.equal(resolveTerminalEncodingSelectValue("utf-8"), "UTF-8");
assert.equal(resolveTerminalEncodingSelectValue("gb18030"), "GB18030");
assert.equal(resolveTerminalEncodingSelectValue("Shift_JIS"), "Shift_JIS");
});
+65
View File
@@ -0,0 +1,65 @@
import React from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
interface TerminalEncodingSelectProps {
value?: string;
inheritedValue?: string;
onValueChange: (value: string | undefined) => void;
className?: string;
}
const SUPPORTED_ENCODINGS = ["UTF-8", "GB18030"] as const;
export const resolveTerminalEncodingSelectValue = (value?: string): string => {
const normalized = value?.trim() || "UTF-8";
return SUPPORTED_ENCODINGS.find(
(encoding) => encoding.toLowerCase() === normalized.toLowerCase(),
) || normalized;
};
export const getTerminalEncodingOptions = (currentValue?: string): string[] => {
const normalizedCurrent = currentValue?.trim();
if (!normalizedCurrent) return [...SUPPORTED_ENCODINGS];
if (SUPPORTED_ENCODINGS.some((encoding) => encoding.toLowerCase() === normalizedCurrent.toLowerCase())) {
return [...SUPPORTED_ENCODINGS];
}
return [normalizedCurrent, ...SUPPORTED_ENCODINGS];
};
export const TerminalEncodingSelect: React.FC<TerminalEncodingSelectProps> = ({
value,
inheritedValue,
onValueChange,
className = "h-10 w-full",
}) => {
const { t } = useI18n();
const effectiveValue = resolveTerminalEncodingSelectValue(value || inheritedValue);
const options = getTerminalEncodingOptions(effectiveValue);
const fallbackValue = "__fallback__";
const selectedValue = value ? resolveTerminalEncodingSelectValue(value) : fallbackValue;
const fallbackLabel = inheritedValue
? `${t("vault.groups.details.inherited")} (${resolveTerminalEncodingSelectValue(inheritedValue)})`
: value
? `${t("common.reset")} (UTF-8)`
: "UTF-8";
return (
<Select
value={selectedValue}
onValueChange={(nextValue) => onValueChange(nextValue === fallbackValue ? undefined : nextValue)}
>
<SelectTrigger className={className}>
<SelectValue placeholder={t("terminal.toolbar.encoding")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={fallbackValue}>{fallbackLabel}</SelectItem>
{options.map((encoding) => (
<SelectItem key={encoding} value={encoding}>
{encoding}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
+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,
+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"
+6 -6
View File
@@ -187,12 +187,12 @@ export const ToolCall = ({
// Auto-focus and auto-scroll when approval is pending
useEffect(() => {
if (isPendingApproval && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
setTimeout(() => approveBtnRef.current?.focus(), 100);
}
if (!isPendingApproval || !cardRef.current) return;
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
return () => clearTimeout(focusTimer);
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
Loaded 100 of 471 files, more files were not shown because too many files have changed in this diff. Show more